Specifications

Karatum Trust Handshake v1

Testnet pilotSource: docs/specs/karatum-handshake-v1.md

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

RoleDoes
IssuerKaratum. Signs credentials, status lists and standing tokens with an Ed25519 key published at https://karatum.com/v1/handshake/issuer.
Holder / proverAn agent. Holds credentials; its wallet key (secp256k1, the same key as its Ethereum address) is the holder key.
VerifierAn 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 deny

The 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):

ClaimMeaning
isshttps://karatum.com/v1/handshake
vcthttps://karatum.com/v1/handshake/vct/agent-certificate/v1 (type metadata served there)
suburn:karatum:agent:<0x wallet, lowercase>
cnf.jwkthe 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, expexp = min(certificate expiry, iat + 7 days)
jtiunique 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):

ClaimValue
taskClassweb3 or software (the certificate's class)
taskTypesKOS task types the class covers, e.g. ["web3.transfer","web3.swap",...]
tier / gradeGuard tier 0-3 / letter D C B A (K ≥ 12 → C, ≥ 18 → B, ≥ 22 → A)
karatsexact K-rating 0-24 (the lower bound of the 95% success interval, scaled to 24)
karatsFloorK rounded down to a band {0,6,12,15,18,21,24}: answers "K ≥ 18" without revealing 19 or 23
evidenceClass, sample, ciLowBps, ciHighBpsweakest evidence class counted E0-E4, verified outcomes, success interval
buildId, certificateId, fingerprintHashwhich build and behavioural fingerprint the certificate rests on
certIssuedAt, certExpiresAtunix 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):

json
{
  "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):

json
{ "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.

  1. Shape. SD-JWT with a key-binding JWT (KB_MISSING otherwise). Bounded sizes (≤ 64 disclosures, ≤ 32 KiB each).
  2. Issuer signature. iss is trusted by this verifier (ISSUER_UNTRUSTED); the signature verifies under one of the issuer's keys with kid matching; the header alg MUST equal the algorithm implied by the key's curve (no none, no alg confusion) (SIGNATURE_INVALID).
  3. Validity. nbf ≤ now ≤ exp within clock skew (default 60 s) (CREDENTIAL_EXPIRED, CREDENTIAL_NOT_YET_VALID); exp − iat ≤ 7 days; vct accepted (VCT_MISMATCH); policy maxCredentialAgeSec (CREDENTIAL_TOO_OLD).
  4. Disclosures. Each disclosure hashes (sha-256 over 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).
  5. Holder binding. cnf.jwk is secp256k1 and its address equals the wallet in sub (HOLDER_BINDING).
  6. Key-binding JWT. typ = kb+jwt, signature by cnf.jwk (KB_INVALID), sd_hash equals the hash of what was presented (DISCLOSURE_TAMPERED), nonce is one this verifier issued and unused (KB_NONCE_UNKNOWN), aud equals the challenge's aud, which is this verifier's identity (in stateless mode, the challenge the caller supplies) (KB_AUDIENCE), iat and the challenge are inside their lifetimes (default 120 s) (KB_EXPIRED), ctx equals what the verifier computed for this request (KB_CONTEXT). The nonce is then consumed atomically: a second use is KB_REPLAY. Consumption happens after the signature check, so only the holder can burn a nonce.
  7. Claim policy on the disclosed claims only (section 5). Undisclosed required claims are DISCLOSURE_REQUIRED.
  8. Revocation status (section 6): the credential's bit in the Token Status List (REVOKED, STATUS_STALE, STATUS_UNAVAILABLE).
  9. Standing (section 6): recall flag and incident window (RECALLED, INCIDENT_WINDOW, STANDING_UNAVAILABLE; a standing token that is too old or expired is STATUS_STALE). A policy that sets noIncidentWithinDays while switching standing off fails with STANDING_UNAVAILABLE: a rule is never silently dropped. An incident whose time cannot be read counts as inside the window.
  10. Chain (section 6): KaratumIncidentLog.isRecalled and 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
FieldDefaultMeaning
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
noRecalltruerefuse 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 / maxStandingAgeSec3600 / 300freshness of the revocation list / standing token
statusList, standing, chainrequired / prefer / prefereach required, prefer (warn when unreachable) or off
offlinefalsenever fetch; rely on stapled tokens
strictDisclosurefalsefail 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 Sepolia 0x9E4857c8cc0dB3AE5B0E26D038B3Bf5f5e9AE8e5, Ethereum Sepolia 0xF879fD5e560eA0E9c7d4D61DBf897Cfbe790Da18.
  • 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 from GET /v1/guard/proof and asks the registry isCertifiedWithProof(leaf, epoch, proof, 0): a view call that checks the leaf against the on-chain root and its freshness. Base Sepolia 0x3799a23902F70C5B08cd133B051F6b147cA50Ae6, Ethereum Sepolia 0xc66B5fc26F07ac24133fCc11F6C8044078bE83D3.
  • 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}? } }. wallet is the key the agent will prove; a verifier SHOULD check that the presented credential's sub equals 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/send with one data part {"type":"karatum.handshake.hello"} and metadata.{ext}.challenge. The server MUST answer with an agent Message whose metadata.{ext}.presentation is 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 with ctx when a job follows.
  • GATE: a server that requires a presentation answers message/send with a Task in state auth-required whose status message carries metadata.{ext}.challenge (with ctx = sha256(canonical JSON of {method, message.parts})). The client retries the same message with metadata.{ext}.presentation. A denial is a JSON-RPC error -32010 with data: {reasons, checks}.
  • Hiring flow: A runs PROVE (with ctx of 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 a tools/call request 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 _meta carries the presentation.
  • GATE: a guarded tool returns isError: true with _meta[...].challenge (ctx = sha256(canonical {tool, args})) until the next call carries a valid presentation. Denial: isError with structuredContent.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_hash and optionally ctx. A replayed presentation is KB_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 aud to the party it is talking to when it can (expectedAud); (2) the verifier binds the presentation to the job with ctx; (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's wallet against sub.
  • 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_hash is signed by the holder; additions, removals and swaps fail (DISCLOSURE_TAMPERED).
  • Alg confusion / key substitution. The header alg must match the key curve; the holder key comes only from the signed cnf.
  • 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 in ZkRepRegistry; a recall publishes the next root immediately). Policy fields a zk proof cannot answer (minSample, minEvidenceClass, noIncidentWithinDays, allowWallets, denyWallets, maxCredentialAgeSec) fail with ZK_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.