Skip to content
Verification spec · EdDSA + RFC 8785 JCS · Schema 1.1.0

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

Signature

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.

Inclusion proof

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.