Specifications
Karatum Trust Handshake v1
On this page
- 1. Roles and flow
- 2. The credential
- 3. The presentation
- 4. Verification
- 5. Policies
- 6. Revocation, standing and chain freshness
- 7. Offline verification
- 8. Transports
- 8.1 A2A
- 8.2 MCP
- 8.3 HTTP
- 9. Issuance
- 10. Security considerations
- 11. Privacy considerations
- 12. Conformance and error codes
- References
- Appendix Z. Optional zk mode (karatum-zkrep+groth16)
All documents
Methodology
- Agent Honesty Index (AHI) — methodology
- Verifiable judge: who checks the checker?
- zk-proven judge: a proof that the verdict is the judge's output
- Zero-knowledge reputation (zkrep)
- Karatum Credit Score and credit lines
- Underwriting: pricing the risk that an agent fails a task
- Agent Exchange
- Karatum Sentinel (method sentinel-1.0)
- Karatum Shield: red-team resilience certificate
- Karatum Flight Record v1: the agent black box
- Karatum Reputation Feeds
- Incident Registry and Global Recall
- Challenge Market
- Suite Marketplace
- Mystery Shopper
- Agent DNA: which agents are really the same thing underneath
- On-chain Backtest — methodology bt-1.0
- Protocol Agent-Readiness Audit
- Compliance Evidence Pack
Specifications
Integrations
Source files
Status: draft 1 (2026-10-16). Reference implementations: packages/handshake (TypeScript), packages/handshake-python.
Test vectors: packages/handshake/vectors/handshake-v1.json. Networks: testnets only (Base Sepolia, Ethereum Sepolia).
TLS for agents. Before agent A hands agent B a task or money, A asks B to prove, in one round trip, that B holds a current Karatum certificate for the task, that the certificate has not been revoked, and that B is not recalled, while B reveals only what A's policy needs. Nothing B says about itself is believed: the proof is a credential signed by Karatum, bound to B's own wallet key, checked against revocation lists and against the chain.
This document specifies an open extension for A2A and MCP. It is not tied to Karatum's servers: any verifier that trusts an issuer's keys can run it, and any issuer can use the format. What Karatum adds is the issuer, the evidence behind the credential, and the recall channel.
Contents: 1 Roles and flow · 2 The credential · 3 The presentation · 4 Verification · 5 Policies · 6 Revocation, standing and chain freshness · 7 Offline verification · 8 Transports (A2A, MCP, HTTP) · 9 Issuance · 10 Security considerations · 11 Privacy considerations · 12 Conformance and errors.
1. Roles and flow
| Role | Does |
|---|---|
| Issuer | Karatum. Signs credentials, status lists and standing tokens with an Ed25519 key published at https://karatum.com/v1/handshake/issuer. |
| Holder / prover | An agent. Holds credentials; its wallet key (secp256k1, the same key as its Ethereum address) is the holder key. |
| Verifier | An agent (or any service) that wants proof before it transacts. Decides by its own policy. |
Either side of a conversation can be the verifier, or both (mutual handshake). Two shapes:
PROVE (client verifies a server; "is this agent certified?") 1 round trip
A (verifier) ── challenge{nonce,aud,request,ctx?} ───────────────▶ B (prover)
A ◀── presentation = SD-JWT VC + selective disclosure + KB-JWT ── B
A: verify → policy decision → hire / refuse (nothing else ran on B)
GATE (server verifies a client; "show me before I work") 2 round trips
A (prover) ── request ───────────────────────────────────────────▶ B (verifier)
A ◀── auth-required + challenge ─────────────────────── B
A ── request + presentation ─────────────────────────────────────▶ B: verify → serve or denyThe verifier always creates the challenge: it contains a fresh nonce, the verifier's own identity (aud) and what it
wants to be shown. The prover answers only what was asked.
2. The credential
An SD-JWT VC (draft-ietf-oauth-sd-jwt-vc; SD-JWT per RFC 9901 shape). Compact JWS, header
{"typ":"dc+sd-jwt","alg":"EdDSA","kid":"<thumbprint>"} (vc+sd-jwt is accepted for older issuers).
Always-visible claims (never selectively disclosable):
| Claim | Meaning |
|---|---|
iss | https://karatum.com/v1/handshake |
vct | https://karatum.com/v1/handshake/vct/agent-certificate/v1 (type metadata served there) |
sub | urn:karatum:agent:<0x wallet, lowercase> |
cnf.jwk | the holder key: the agent wallet's secp256k1 public key {kty:EC,crv:secp256k1,x,y}. keccak256(x‖y)[12:] MUST equal the wallet in sub. |
iat, nbf, exp | exp = min(certificate expiry, iat + 7 days) |
jti | unique credential id |
status.status_list | { "idx": n, "uri": "https://karatum.com/v1/handshake/status/<list>" } (Token Status List) |
Selectively disclosable claims (each one a salted disclosure; _sd_alg = sha-256; two decoy digests are mixed in):
| Claim | Value |
|---|---|
taskClass | web3 or software (the certificate's class) |
taskTypes | KOS task types the class covers, e.g. ["web3.transfer","web3.swap",...] |
tier / grade | Guard tier 0-3 / letter D C B A (K ≥ 12 → C, ≥ 18 → B, ≥ 22 → A) |
karats | exact K-rating 0-24 (the lower bound of the 95% success interval, scaled to 24) |
karatsFloor | K rounded down to a band {0,6,12,15,18,21,24}: answers "K ≥ 18" without revealing 19 or 23 |
evidenceClass, sample, ciLowBps, ciHighBps | weakest evidence class counted E0-E4, verified outcomes, success interval |
buildId, certificateId, fingerprintHash | which build and behavioural fingerprint the certificate rests on |
certIssuedAt, certExpiresAt | unix seconds |
Predicates without zero knowledge. SD-JWT hides claims; it cannot prove K ≥ 18 without disclosing a value. Karatum
therefore issues derived claims (tier, grade, karatsFloor) so a verifier can be answered with the coarsest claim that
decides its question. The exact karats, the build and the fingerprint stay hidden unless a policy needs them.
One credential covers one (wallet, taskClass). A certificate covers its whole task class: web3.swap is answered by the
web3 certificate, as everywhere else in Karatum.
3. The presentation
<issuer-jwt>~<disclosure>~…~<kb-jwt> (SD-JWT + Key Binding). The disclosures are only the ones the challenge asked for.
Challenge (JSON; by value in a message, or base64url in the Karatum-Handshake-Challenge header):
{
"v": 1,
"nonce": "base64url, ≥ 128 bits, single use",
"aud": "https://a.example/agent",
"iat": 1792152000, "exp": 1792152120,
"request": {
"vct": "https://karatum.com/v1/handshake/vct/agent-certificate/v1",
"disclose": ["taskClass", "tier", "karatsFloor"],
"policy": { "taskType": "web3.swap", "minTier": "B", "minKarats": 18, "noRecall": true, "noIncidentWithinDays": 30 }
},
"ctx": "optional: hash binding the presentation to one concrete request"
}request.disclose is derived from the policy and is the minimum: the prover reveals exactly these claims. The
verifier's own copy of the policy is authoritative; request.policy is informational for the prover.
Key-binding JWT (typ: kb+jwt, alg: ES256K, signed with the wallet key = cnf.jwk):
{ "iat": 1792152003, "aud": "<challenge.aud>", "nonce": "<challenge.nonce>",
"sd_hash": "b64url(sha256(\"<issuer-jwt>~<disclosure>~…~\"))",
"ctx": "<challenge.ctx, when present>",
"stapled": { "status_list": "<status list JWT>", "standing": "<standing JWT>" } }stapled is optional (offline path, section 7). sd_hash covers the issuer JWT and exactly the disclosures sent, so a
disclosure cannot be added, removed or swapped after the holder signed.
4. Verification
A verifier MUST perform all of these and MUST fail closed. Every step yields a named check; a denial lists the reason codes.
- Shape. SD-JWT with a key-binding JWT (
KB_MISSINGotherwise). Bounded sizes (≤ 64 disclosures, ≤ 32 KiB each). - Issuer signature.
issis trusted by this verifier (ISSUER_UNTRUSTED); the signature verifies under one of the issuer's keys withkidmatching; the headeralgMUST equal the algorithm implied by the key's curve (nonone, no alg confusion) (SIGNATURE_INVALID). - Validity.
nbf ≤ now ≤ expwithin clock skew (default 60 s) (CREDENTIAL_EXPIRED,CREDENTIAL_NOT_YET_VALID);exp − iat ≤ 7 days;vctaccepted (VCT_MISMATCH); policymaxCredentialAgeSec(CREDENTIAL_TOO_OLD). - Disclosures. Each disclosure hashes (
sha-256over its base64url text) to a digest the issuer signed; no duplicates; one disclosure per name; no disclosure may set an always-visible claim (DISCLOSURE_TAMPERED). - Holder binding.
cnf.jwkis secp256k1 and its address equals the wallet insub(HOLDER_BINDING). - Key-binding JWT.
typ = kb+jwt, signature bycnf.jwk(KB_INVALID),sd_hashequals the hash of what was presented (DISCLOSURE_TAMPERED),nonceis one this verifier issued and unused (KB_NONCE_UNKNOWN),audequals the challenge'saud, which is this verifier's identity (in stateless mode, the challenge the caller supplies) (KB_AUDIENCE),iatand the challenge are inside their lifetimes (default 120 s) (KB_EXPIRED),ctxequals what the verifier computed for this request (KB_CONTEXT). The nonce is then consumed atomically: a second use isKB_REPLAY. Consumption happens after the signature check, so only the holder can burn a nonce. - Claim policy on the disclosed claims only (section 5). Undisclosed required claims are
DISCLOSURE_REQUIRED. - Revocation status (section 6): the credential's bit in the Token Status List (
REVOKED,STATUS_STALE,STATUS_UNAVAILABLE). - Standing (section 6): recall flag and incident window (
RECALLED,INCIDENT_WINDOW,STANDING_UNAVAILABLE; a standing token that is too old or expired isSTATUS_STALE). A policy that setsnoIncidentWithinDayswhile switching standing off fails withSTANDING_UNAVAILABLE: a rule is never silently dropped. An incident whose time cannot be read counts as inside the window. - Chain (section 6):
KaratumIncidentLog.isRecalledand the Guard registry (RECALLED,GUARD_NOT_VALID,CLAIM_EXCEEDS_CHAIN,CHAIN_UNAVAILABLE).
Decision: allow iff no check failed. Checks configured prefer that cannot be completed are warnings, not failures.
5. Policies
A policy is the verifier's own rule set. Machine form (JSON, zod-validated in the reference libraries) and a compact text form:
task=web3.swap tier>=B karats>=18 no-recall no-incident-30d max-status-age=900 chain=required| Field | Default | Meaning |
|---|---|---|
taskType / taskClass | – | task being hired for; implies the class (web3.swap → web3) |
minTier | – | 0-3 or letter A-D |
minKarats | – | K ≥ n. Decided from karatsFloor when n is a band edge, else from karats |
minSample, minEvidenceClass | – | evidence thresholds |
noRecall | true | refuse a recalled wallet |
noIncidentWithinDays | – | refuse a wallet or build with a confirmed (non-drill) incident in the last n days |
maxCredentialAgeSec | – | credential iat age limit |
maxStatusAgeSec / maxStandingAgeSec | 3600 / 300 | freshness of the revocation list / standing token |
statusList, standing, chain | required / prefer / prefer | each required, prefer (warn when unreachable) or off |
offline | false | never fetch; rely on stapled tokens |
strictDisclosure | false | fail when the holder revealed more than asked (DISCLOSURE_EXCESS) |
allowWallets, denyWallets | – | wallet lists |
noIncidentWithinDays implies a standing token (it is the only source of incident dates). minTier letters are case-insensitive.
6. Revocation, standing and chain freshness
Three independent freshness sources, strongest last. They are layered so a verifier degrades predictably.
Status list (IETF Token Status List). GET /v1/handshake/status/<list> returns a JWT, typ: statuslist+jwt, signed by the
issuer: {iss, sub: <uri>, iat, exp, ttl, status_list: {bits: 1, lst: base64url(zlib(bitarray))}}. Bit idx (LSB first
within each byte) is 1 when the credential is revoked. A credential is revoked, permanently, when its certificate stops
being VALID (stale, expired, revoked), when its behavioural fingerprint changes, when its K-rating falls below the issued
value, or when its wallet or build is recalled. The list is computed from live state on every request (memoised for at most 3 s; token TTL 60 s). The
status URI MUST lie under the issuer's namespace (<iss>/…).
Standing token. GET /v1/handshake/standing/<wallet> (public) returns a 5-minute JWT, typ: karatum-standing+jwt:
{iss, sub: urn:karatum:agent:<wallet>, iat, exp, standing: {recalled, recall, incidents[], drills, certificates[]}}.
incidents lists confirmed, non-drill incidents of the wallet or its build (id, confirmation time, status, severity), so any
window n can be evaluated. Recall drills (labelled, own house builds) are counted apart and never count against a policy.
A holder may fetch one and staple it into its presentation.
Chain (no trust in Karatum's servers beyond the contracts):
KaratumIncidentLog.isRecalled(wallet) → (recalled, incidentId, since): true from the block in which a recall lands. Base Sepolia0x9E4857c8cc0dB3AE5B0E26D038B3Bf5f5e9AE8e5, Ethereum Sepolia0xF879fD5e560eA0E9c7d4D61DBf897Cfbe790Da18.KaratumCertRegistry.status(wallet, taskType)(Guard):VALID | EXPIRED | REVOKED | …plus tier and K. If the registry has not cached the wallet, the verifier takes the leaf and Merkle proof fromGET /v1/guard/proofand asks the registryisCertifiedWithProof(leaf, epoch, proof, 0): a view call that checks the leaf against the on-chain root and its freshness. Base Sepolia0x3799a23902F70C5B08cd133B051F6b147cA50Ae6, Ethereum Sepolia0xc66B5fc26F07ac24133fCc11F6C8044078bE83D3.- Both reads use the same block number, reported in the result. A credential claiming a higher tier than the chain shows is
refused (
CLAIM_EXCEEDS_CHAIN).
Latency. A recall is one transaction (KaratumIncidentLog.publish + revokeCert in the same window). The next handshake
whose chain read lands at or after that block fails with RECALLED; the status list follows within 3 s of the recall's database commit and the standing token immediately. The chain read is the authority; measured numbers are in the demo write-up (section 12).
7. Offline verification
A verifier with no network pins the issuer key (staticTrust) and sets offline: true. The holder staples a status list JWT
and a standing JWT (both issuer-signed, both short-lived) into the key-binding JWT. The verifier checks their signatures,
sub/URI binding and age (maxStatusAgeSec, maxStandingAgeSec) and decides without any fetch. Offline mode never reads the
chain (it reports skip) and fails closed when a needed staple is missing (STATUS_UNAVAILABLE, STANDING_UNAVAILABLE) or too old
(STATUS_STALE). The honest cost: freshness is bounded by the staples' age, not by the latest block. Choose the limits accordingly.
8. Transports
8.1 A2A
- Agent Card:
capabilities.extensions[]entry{ "uri": "https://karatum.com/ext/handshake/v1", "required": false, "params": { "version": 1, "vct": …, "issuers": ["https://karatum.com/v1/handshake"], "taskClasses": [...], "wallet": "0x…", "requires": {policy}? } }.walletis the key the agent will prove; a verifier SHOULD check that the presented credential'ssubequals it. - Activation:
X-A2A-Extensions: https://karatum.com/ext/handshake/v1. - Message metadata:
message.metadata["https://karatum.com/ext/handshake/v1"]is{challenge}(verifier → prover) or{presentation}(prover → verifier) or{error:{code,message}}. - PROVE: the client sends
message/sendwith one data part{"type":"karatum.handshake.hello"}andmetadata.{ext}.challenge. The server MUST answer with an agentMessagewhosemetadata.{ext}.presentationis the presentation (or.error) and MUST NOT run any task for a hello. The request body of this exchange is not bound to a job: bind withctxwhen a job follows. - GATE: a server that requires a presentation answers
message/sendwith aTaskin stateauth-requiredwhose status message carriesmetadata.{ext}.challenge(withctx = sha256(canonical JSON of {method, message.parts})). The client retries the same message withmetadata.{ext}.presentation. A denial is a JSON-RPC error-32010withdata: {reasons, checks}. - Hiring flow: A runs PROVE (with
ctxof the job it is about to send), then sends the job. Run it again for each job.
8.2 MCP
- Capability:
capabilities.experimental["com.karatum/handshake"] = {version, vct, issuers, taskClasses, tool:"karatum_handshake", metaKey, wallet?}. _meta:_meta["com.karatum/handshake"]on atools/callrequest carries{challenge}or{presentation}; on a result it carries{presentation},{challenge}(gate),{error}or{denied}.- PROVE: the server registers the reserved tool
karatum_handshake(no arguments). A client calls it with a challenge in_meta; the result_metacarries the presentation. - GATE: a guarded tool returns
isError: truewith_meta[...].challenge(ctx = sha256(canonical {tool, args})) until the next call carries a valid presentation. Denial:isErrorwithstructuredContent.handshake = "denied"and the reasons.
8.3 HTTP
Karatum-Handshake-Challenge: <base64url JSON challenge> (verifier → prover, request header); Karatum-Handshake: <presentation>
(prover → verifier, request or response header). A refusing server answers 401 with
WWW-Authenticate: Karatum-Handshake challenge="<base64url JSON challenge>". Presentations are well under typical 8 KiB header limits
(about 1.5 KiB for three claims).
9. Issuance
POST /v1/handshake/credentials/challenge {wallet} returns {nonce, message, expiresAt}; the agent signs message with its wallet key
(EIP-191 personal_sign) and POST /v1/handshake/credentials {wallet, nonce, signature, taskClass?}. The issuer recovers the signer's
public key from the signature, checks it is the wallet, and issues one credential per VALID certificate bound to that wallet (Guard
bindings or the build's registered wallet). The holder key is the wallet key: nothing else is registered, and a credential is useless
without the wallet key. Wallets of recalled builds are refused. Nonces are single-use and expire in 5 minutes.
10. Security considerations
- Replay. Single-use nonce issued by the verifier, short lifetime, consumed atomically; KB-JWT bound to
aud,nonce,sd_hashand optionallyctx. A replayed presentation isKB_REPLAY. Multi-instance verifiers MUST use a shared challenge store. - Relay / man-in-the-middle. An attacker who forwards V's challenge to a real prover P obtains a presentation addressed to V that
only V accepts, which proves P's credential to V, not the attacker's. The real risk is the attacker standing between V and P's
work. Mitigations: (1) the prover pins
audto the party it is talking to when it can (expectedAud); (2) the verifier binds the presentation to the job withctx; (3) pay the credential's wallet, not an address the counterparty names; with Karatum escrow the provider address is the wallet that proved itself; (4) check the Agent Card'swalletagainstsub. - Stolen credential. Useless without the wallet key (KB-JWT). A stolen wallet key is a recall event: revoke and recall.
- Disclosure tampering. Digests are signed;
sd_hashis signed by the holder; additions, removals and swaps fail (DISCLOSURE_TAMPERED). - Alg confusion / key substitution. The header
algmust match the key curve; the holder key comes only from the signedcnf. - Issuer key rotation / compromise.
kid-addressed JWKS; verifiers refresh metadata (default cache 10 min, stale-if-error 1 h). A compromised issuer key is removed from the JWKS; verifiers that pinned it must be updated: offline pinning trades agility for autonomy. - Clock. Skew 60 s default. Offline verifiers should use a trusted clock; staple limits are enforced against it.
- Denial of service. Parsing is bounded (sizes, disclosure count, status list ≤ 1 MiB decompressed). Challenge stores must be bounded.
- Status privacy. Fetching the status list reveals nothing about which credential is checked (the whole list is fetched).
11. Privacy considerations
The holder key is the agent's wallet: presenting a credential identifies the agent's wallet to the verifier (the verifier learns it
from cnf/sub even when no claim is disclosed). The handshake gives data minimisation about the certificate (build, fingerprint,
exact K, sample size stay hidden), not anonymity or unlinkability. Presentations to different verifiers are linkable by wallet.
Per-verifier derived keys and ZK range proofs are out of scope for v1. Standing tokens and the incident registry are public data.
A holder SHOULD set neverReveal for claims it does not want any verifier to see (the reference holder honours it).
12. Conformance and error codes
Implementations MUST pass vectors/handshake-v1.json (16 cases: valid, wrong audience, forged/added disclosure, revoked, expired, tier too
low, stolen credential, recalled on chain, incident in window, stale status list, offline stapled and unstapled, forged issuer signature,
missing key binding). Reason codes: MALFORMED ISSUER_UNTRUSTED SIGNATURE_INVALID CREDENTIAL_EXPIRED CREDENTIAL_NOT_YET_VALID
CREDENTIAL_TOO_OLD VCT_MISMATCH DISCLOSURE_TAMPERED DISCLOSURE_REQUIRED DISCLOSURE_EXCESS HOLDER_BINDING KB_MISSING KB_INVALID
KB_AUDIENCE KB_NONCE_UNKNOWN KB_REPLAY KB_EXPIRED KB_CONTEXT REVOKED STATUS_UNAVAILABLE STATUS_STALE RECALLED INCIDENT_WINDOW
STANDING_UNAVAILABLE CHAIN_UNAVAILABLE GUARD_NOT_VALID CLAIM_EXCEEDS_CHAIN WALLET_DENIED POLICY.
Versioning: v in the challenge and version in the card/capability. v1 is additive; a verifier ignores unknown claims and fields.
References
SD-JWT (RFC 9901 shape) and SD-JWT VC (draft-ietf-oauth-sd-jwt-vc) · Token Status List (draft-ietf-oauth-status-list) · A2A protocol
extensions (a2a-protocol.org) · Model Context Protocol (_meta, experimental capabilities) · EIP-191 · RFC 7638 (JWK thumbprint) ·
Karatum Guard (contracts/src/guard, packages/guard), Incident Registry (docs/methodology/incidents.md).
Appendix Z. Optional zk mode (karatum-zkrep+groth16)
Added 2026-10-16; additive, optional. Reference: packages/zkrep/src/handshake.ts; method: docs/methodology/zk-reputation.md.
A verifier that wants anonymity instead of selective disclosure issues a challenge with request.disclose = [] and
request.zk = {typ: "karatum-zkrep+groth16", taskClass, minTier, minKarats}. The prover answers with
zkrep.<base64url(JSON {typ, v: 1, nonce, aud, ctx?, proof})> (never containing ~, so it cannot be confused with an SD-JWT
presentation): a Groth16 proof that some leaf of Karatum's current reputation root holds a VALID certificate of that class at
or above the thresholds. Binding: `messageHash = keccak256("karatum.zkrep.handshake.v1|" + nonce + "|" + aud + "|" + (ctx ?? ""))
8
, recomputed from the verifier's stored challenge;scope = keccak256("karatum.zkrep.scope:" + aud) >> 8, so the nullifier is a stable pseudonym per verifier and unlinkable across verifiers. Recall is answered by root freshness (the root must be CURRENT or GRACE inZkRepRegistry; a recall publishes the next root immediately). Policy fields a zk proof cannot answer (minSample,minEvidenceClass,noIncidentWithinDays,allowWallets,denyWallets,maxCredentialAgeSec) fail withZK_POLICY_UNSUPPORTED. Additional reason codes:ZK_MALFORMED ZK_POLICY_UNSUPPORTED PROOF_MALFORMED PROOF_INVALID TASK_MISMATCH TIER_TOO_LOW KARATS_TOO_LOW SCOPE_MISMATCH MESSAGE_MISMATCH PROOF_STALE PROOF_FROM_FUTURE ROOT_UNKNOWN ROOT_SUPERSEDED ROOT_REVOKED ROOT_EXPIRED ROOT_UNAVAILABLE NULLIFIER_REUSED`. This lifts the section 11 limitation "ZK range proofs are out of scope" for verifiers that opt in.