Verify a passport without us
An Agent Trust Passport is only portable if a counterparty can check it on their own. Every passport carries an Ed25519 signature, and every passport is hashed into a daily Merkle root we publish. Both are verifiable offline, with the network unplugged. This page is the spec and a working implementation.
Two checks, two different questions
Did Agenstry issue this exact document? An EdDSA signature over the RFC 8785 canonicalisation of the passport. Any edit — one flipped byte in one signal — breaks it.
Did Agenstry say it on that date? A Merkle path from this passport to a daily root published before now. We cannot back-date a friendlier passport, because that day's root is already out.
You want both. A signature alone would let us silently re-sign history; an inclusion proof alone wouldn't bind the content to us.
The three public inputs
curl -s https://agenstry.com/api/v1/passport/example.com > passport.json
curl -s https://agenstry.com/api/v1/passport/example.com/proof > proof.json
curl -s https://agenstry.com/.well-known/jwks.json > jwks.json
Fetch once, verify forever. The JWKS holds the key that signed it,
selected by the passport's key_id. Retired keys stay
published permanently — a passport you archived in 2026 still verifies
after we rotate, which is the whole point of archiving it.
Check 1 — the signature
The signature is a detached compact JWS (header..signature,
empty payload segment — the payload is the document you're holding).
The signed bytes are the JCS canonicalisation of the passport with the
signature key removed, and nothing else removed:
key_id, signed_at and the whole
verification block are inside the signature, so nobody can
repoint jwks_url at a key they control.
import base64, json
from cryptography.hazmat.primitives.asymmetric import ed25519
def jcs(obj): # RFC 8785, sufficient for the value types a passport holds
return json.dumps(obj, sort_keys=True, separators=(",", ":"),
ensure_ascii=False).encode()
def b64d(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def b64e(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
passport = json.load(open("passport.json"))
jwks = json.load(open("jwks.json"))
header_b64, _, sig_b64 = passport["signature"].split(".")
header = json.loads(b64d(header_b64))
assert header["alg"] == "EdDSA"
assert header["typ"] == "agenstry-passport+jws" # not a badge or card signature
jwk = next(k for k in jwks["keys"]
if k["kid"] == header["kid"] and k["kty"] == "OKP")
key = ed25519.Ed25519PublicKey.from_public_bytes(b64d(jwk["x"]))
payload = {k: v for k, v in passport.items() if k != "signature"}
key.verify(b64d(sig_b64), f"{header_b64}.{b64e(jcs(payload))}".encode())
print("signature OK —", header["kid"])
Check 2 — the inclusion proof
The leaf commits to verification.content_digest: a SHA-256
over the JCS form of just
schema_version, subject, signals
and summary. That subset is deliberate — it excludes
issued_at and signed_at (which change on every
fetch), our own issuer URLs, and history/tier
(which differ between the free and paid views of the same agent). One leaf
therefore means one agent's trust state on one day, unambiguously.
import hashlib
def sha256(b): return hashlib.sha256(b).digest()
proof = json.load(open("proof.json"))["proof"]
subset = {k: passport[k] for k in
("schema_version", "subject", "signals", "summary") if k in passport}
digest = "sha256:" + hashlib.sha256(jcs(subset)).hexdigest()
assert digest == proof["content_digest"], "this proof is about a different document"
# Leaf: sha256("passport" \0 domain \0 digest)
domain = passport["subject"]["domain"]
node = sha256(("passport\0" + domain + "\0" + digest).encode())
assert node.hex() == proof["leaf"]
# Walk up to the passports sub-root. An odd node carries up unchanged,
# so a level with no sibling emits no step.
for step in proof["audit_path"]:
sib = bytes.fromhex(step["hash"])
node = sha256(node + sib) if step["position"] == "right" else sha256(sib + node)
assert node.hex() == proof["passports_sub_root"]
# Rebuild the day's root from the sub-roots, in the published order.
def merkle_root(leaves):
layer = list(leaves)
while len(layer) > 1:
layer = [sha256(layer[i] + layer[i+1]) if i+1 < len(layer) else layer[i]
for i in range(0, len(layer), 2)]
return layer[0].hex()
values = {**proof["sibling_sub_roots"], "passports": node.hex()}
nodes = [sha256(f"{label}:".encode() + values[label].encode())
for label in proof["sub_root_order"]]
assert merkle_root(nodes) == proof["merkle_root"]
print("anchored in the root published for", proof["date"])
Cross-check proof.merkle_root against the root listed on
/transparency for that date. If they agree, the
passport was in a tree we published — and we published it before you asked.
Signed but not yet anchored
Anchoring runs once per UTC day, the morning after the day closes, over
the agents we saw that day. So a passport fetched hours after an agent's
probes moved is correctly signed but not yet in a root — the document says
so in verification.inclusion_proof_available, and the proof
endpoint returns not_anchored rather than a proof that
wouldn't check out. An agent indexed today is first provable tomorrow.
A consequence worth stating plainly: an inclusion proof attests to the trust state as measured when that day's tree was built, not to a live document. That's what makes it a historical record rather than a second copy of the API response.
Ready-made verifier
The repository ships scripts/verify_passport_offline.py, which
is the code above with argument parsing and no imports from our application
— standard library plus cryptography. Exit code 0 means every
requested check passed.
python verify_passport_offline.py \
--passport passport.json --proof proof.json --jwks jwks.json
SIGNATURE PASS signed by agenstry-passport-ed25519-1
INCLUSION PASS anchored in the root published for 2026-08-05
There is also POST https://agenstry.com/api/v1/passport/verify, which
returns the same verdict. It's a convenience for quick checks — but if you
verify against an endpoint we control, you are still trusting us. The
offline path is the one that makes this evidence.
Key rotation
Each signature names the key that made it in key_id. When we
rotate, the new key signs new passports and the retired public key stays in
the JWKS indefinitely, so passports signed under it keep
verifying. We don't expire retired passport keys the way a normal JWKS
cache window would: third parties archive these documents, and a key we
stopped publishing would quietly turn their archive into unverifiable JSON.