Skip to main content

Agent identity and trust (KYA)

Verifiable agent identity (did:web + Ed25519), signed reputation, and the register / rotate / revoke key lifecycle. Know Your Agent for the money side.

5 min read
View MarkdownEdit on GitHub

Agent identity and trust (KYA)

Why it exists

Before an agent moves money, a counterparty needs to know two separate things: who the agent is (a stable, verifiable identity) and what it is allowed to do right now (a scoped, revocable authorization). CodeSpar keeps those two concerns apart so each has its own lifecycle.

Identity is not authorization

These are two different objects with two different lifetimes.

  • Identity is the agent's cryptographic key. It is long lived and rotatable. An agent registers once, holds an Ed25519 keypair, and keeps the same decentralized identifier (did:web) across its whole life. The public key is published in a DID document that anyone can read; the private key never leaves the vault.
  • Authorization is a mandate: a signed envelope that says "this agent may spend up to X, for these purposes, until this date." It is scoped, it expires, and it can be revoked at any moment without touching the agent's identity.

Rotating a key does not change what an agent is allowed to spend. Revoking a mandate does not change who the agent is. A verifier checks identity against the DID document and authorization against the mandate, independently.

How the identifier is shaped

Every registered agent gets a did:web identifier derived from stable inputs, and every key it holds gets a key id (kid) under that DID:

agent_did = did:web:id.codespar.dev:<org_id>:<agent_id>
kid       = <agent_did>#<n>            # n starts at 1, increments on each rotation

Because the DID resolves as a standard did:web, any off-the-shelf resolver can fetch the agent's keys with no CodeSpar credential.

Public identity surface (no auth)

Four read-only endpoints let one agent verify another agent's identity and reputation with no CodeSpar credential at all. They sit outside the authenticated /v1 scope. The raw CPF/CNPJ behind an agent is never exposed; only a KYC boolean is.

Base URL: https://api.codespar.dev

EndpointReturns
GET /v1/agents/:didStatus, non-revoked keys, and the KYC boolean
GET /v1/agents/:did/did.jsonThe did:web document (JsonWebKey2020 / Ed25519)
GET /v1/agents/:did/reputationAn issuer-signed reputation attestation
GET /v1/agents/:did/erc8004An ERC-8004 style export (identity + reputation + validation)

The :did path segment is the full DID, colons included (for example did:web:id.codespar.dev:org_2n...:refund-bot). A URL path segment may contain colons, so no encoding is needed.

Agent status

curl -sS https://api.codespar.dev/v1/agents/did:web:id.codespar.dev:org_2n...:refund-bot
{
  "did": "did:web:id.codespar.dev:org_2n...:refund-bot",
  "status": "active",
  "principal_kyc_verified": true,
  "display_name": "Refund bot",
  "created_at": "2026-06-30T12:00:00.000Z",
  "keys": [{ "kid": "did:web:id.codespar.dev:org_2n...:refund-bot#2", "status": "active" }]
}

Revoked keys are omitted. A retired key still appears (it can still verify what it signed before it retired), but only a key with status: "active" signs anything new.

DID document

curl -sS https://api.codespar.dev/v1/agents/did:web:id.codespar.dev:org_2n...:refund-bot/did.json
{
  "@context": [
    "https://www.w3.org/ns/did/v1",
    "https://w3id.org/security/suites/jws-2020/v1"
  ],
  "id": "did:web:id.codespar.dev:org_2n...:refund-bot",
  "verificationMethod": [
    {
      "id": "did:web:id.codespar.dev:org_2n...:refund-bot#2",
      "type": "JsonWebKey2020",
      "controller": "did:web:id.codespar.dev:org_2n...:refund-bot",
      "publicKeyJwk": { "kty": "OKP", "crv": "Ed25519", "x": "b64url-raw-32-byte-key" }
    }
  ],
  "assertionMethod": ["did:web:id.codespar.dev:org_2n...:refund-bot#2"],
  "authentication": ["did:web:id.codespar.dev:org_2n...:refund-bot#2"]
}

The response carries content-type: application/did+json.

Reputation attestation

Reputation is derived only from the immutable audit chain and the receipt record, never self asserted. The score is returned with the exact audit chain head it was computed against and signed by the single platform issuer key, so the attestation is itself verifiable: a third party recomputes the score from the same inputs and checks the Ed25519 signature with the issuer's public key.

{
  "version": "codespar-reputation-1",
  "subject_did": "did:web:id.codespar.dev:org_2n...:refund-bot",
  "principal_kyc_verified": true,
  "score": 82,
  "components": { "good_standing": 1, "reliability": 0.98, "dispute_rate": 0.01, "track_record": 0.6 },
  "inputs": { "settled_count": 240, "settled_volume_minor": 1830000, "exception_count": 2, "revoked_count": 0 },
  "chain_head_hash": "…",
  "chain_head_sequence": 5123,
  "as_of": "2026-07-01T09:00:00.000Z",
  "issuer_did": "did:web:id.codespar.dev",
  "issuer_kid": "did:web:id.codespar.dev#1",
  "signature_alg": "Ed25519",
  "signature": "b64url-issuer-signature"
}

The score reflects observed history only. An agent with little activity carries an insufficient_history flag; a high score is evidence, not a guarantee.

ERC-8004 export

The same identity and reputation, plus the audit chain head as a validation section, shaped into an ERC-8004 style bundle and signed as one unit so the pieces cannot be recombined:

{
  "schema": "erc-8004-attestation-1",
  "agent_id": "did:web:id.codespar.dev:org_2n...:refund-bot",
  "identity": { "did": "…", "status": "active", "principal_kyc_verified": true, "did_document": { "…": "…" } },
  "reputation": { "…": "the signed attestation above" },
  "validation": { "method": "codespar-audit-chain", "chain_head_hash": "…", "chain_head_sequence": 5123 },
  "issuer_did": "did:web:id.codespar.dev",
  "issuer_kid": "did:web:id.codespar.dev#1",
  "exported_at": "2026-07-01T09:00:00.000Z",
  "signature_alg": "Ed25519",
  "signature": "b64url-bundle-signature"
}

Verify a mandate offline

Because the agent's public key lives in the DID document, a counterparty can verify that the agent really authorized a spend without ever calling CodeSpar. A V3 mandate carries agent_sig, an Ed25519 signature over the mandate's canonical signing string, plus the kid that produced it. Fetch that key from the DID document and check the signature locally.

import { createPublicKey, verify } from "node:crypto";

// 1. From GET /v1/agents/<did>/did.json, pick the key that signed this mandate.
const jwk = didDocument.verificationMethod.find((v) => v.id === mandate.kid).publicKeyJwk;
const agentPublicKey = createPublicKey({ key: jwk, format: "jwk" });

// 2. Rebuild the canonical signing string (V3: 14 colon-joined fields, fixed order).
const canonical = [
  mandate.format_version, mandate.id, mandate.agent_id, mandate.type,
  mandate.amount, mandate.currency, mandate.purposes /* sorted, escaped, comma-joined */,
  mandate.expires_at, mandate.max_amount ?? "", mandate.parent_id ?? "",
  mandate.denomination ?? "", mandate.secret_version, mandate.principal_kyc_ref ?? "",
  mandate.kid,
].join(":");

// 3. Ed25519 verify, no digest, no CodeSpar call.
const ok = verify(null, Buffer.from(canonical, "utf8"), agentPublicKey, Buffer.from(mandate.agent_sig, "base64url"));
// ok === true  ->  the key published in the DID document really signed this mandate.

Illustrative. The field order above is the exact V3 canonical layout, but the purposes member has its own sort-and-escape rules. In practice, use the published mandate codec rather than joining fields by hand, so your bytes match ours exactly. The same string is also covered by the platform issuer signature, so a verifier can confirm CodeSpar issued the mandate as well as that the agent signed it.

Manage agents (authenticated)

Registration and key lifecycle are the write side. They are authenticated in the /v1 dual scope, so both a bearer API key and dashboard service auth reach them. See Authentication for the header contract.

The :orgId in the path is decorative for REST shape. The authoritative tenant is always the authenticated context; a path that names a different org is rejected 403 forbidden before any lookup.

Register an agent

POST /v1/orgs/:orgId/agents

{
  "agent_id": "refund-bot",
  "display_name": "Refund bot",
  "principal_ref": "<proofed CPF/CNPJ KYC handle>"
}

Mints the agent's first key. agent_id must be a URL-safe did:web path segment. Response 201 Created:

{
  "agent_did": "did:web:id.codespar.dev:org_2n...:refund-bot",
  "kid": "did:web:id.codespar.dev:org_2n...:refund-bot#1",
  "pubkey": "base64-raw-32-byte-key",
  "status": "active"
}

The principal_ref binds a proofed principal to the identity. It is stored, never echoed back, and never exposed on the public surface, which shows only principal_kyc_verified.

Rotate a key

POST /v1/orgs/:orgId/agents/:agentId/keys/rotate

Retires the current active key and mints a fresh one under a new kid. The retired key still verifies mandates it signed before rotation, so past authorizations stay valid; it just stops signing anything new. Response 201 Created:

{
  "agent_did": "did:web:id.codespar.dev:org_2n...:refund-bot",
  "kid": "did:web:id.codespar.dev:org_2n...:refund-bot#2",
  "pubkey": "base64-raw-32-byte-key",
  "retired_kid": "did:web:id.codespar.dev:org_2n...:refund-bot#1",
  "status": "active"
}

Revoke a key

POST /v1/orgs/:orgId/agents/:agentId/keys/:kid/revoke

Hard revoke for the compromise path. A revoked key verifies nothing afterward, whenever it was issued, and drops out of the DID document. The :kid segment is a full key id (did:web:...#<n>); its # must be percent-encoded as %23. Response 200 OK:

{
  "kid": "did:web:id.codespar.dev:org_2n...:refund-bot#1",
  "status": "revoked",
  "revoked_at": "2026-07-01T09:30:00.000Z"
}

Error codes

CodeHTTPWhen
org_id_not_did_safe400The org id is not a valid did:web path segment
agent_id_not_did_safe400The agent_id is not a URL-safe handle
agent_already_registered409The agent already holds an active key (register is not a silent rotate)
agent_not_found404Rotate targeted an unknown agent for this org
no_active_key409Rotate found no active key to retire (agent never registered)
agent_key_not_found404Revoke targeted a kid that does not belong to the agent
key_already_revoked409The key was already revoked
forbidden403The path :orgId does not match the authenticated context

See also

  • Trust page - the product overview of Know Your Agent
  • Wallets - mandates, the scoped authorization side
  • Audit chain - the record reputation is derived from
  • Facilitator - ingest an externally-signed authorization and execute it under governance
  • Authentication - the header contract for the management endpoints
Agent identity and trust (KYA) | CodeSpar