How Bolyra Agent Credentials Are Issued, Registered, Revoked, and Rotated
Every post so far explained verification: how the gateway checks proofs, enforces policies, blocks replays. None explained where AI agent credentials come from, how they get registered in the agent identity registry, or what happens when they expire, rotate, or must be revoked.
This post covers the full credential lifecycle.
1. Issuance: creating a credential
An operator creates an agent credential by calling createAgentCredential(). Four inputs:
- modelHash (bigint): hash of the model identifier, e.g.
sha256("gpt-4o") - operatorPrivateKey (bigint | Buffer): the operator's EdDSA private key
- permissions (Permission[]): array of permission flags (READ_DATA, WRITE_DATA, FINANCIAL_SMALL, etc.)
- expiryTimestamp (bigint): Unix seconds when the credential expires
The SDK validates the inputs (expiry must be in the future, cumulative bit encoding must be consistent), then computes a commitment:
commitment = Poseidon5(modelHash, operatorPubKey.x, operatorPubKey.y,
permissionBitmask, expiryTimestamp)
The commitment is a single field element that binds all five parameters. Changing any input changes the commitment. The operator also signs the commitment with EdDSA. This signature is verified inside the AgentPolicy circuit (not just the SDK), so a proof cannot be generated without a valid operator signature.
The credential object contains the inputs, the commitment, and the signature. It does not contain the private key.
Access control: anyone can compute a commitment, but only the contract owner can call enrollAgent() to register it on-chain. This prevents unauthorized credential enrollment.
const credential = await createAgentCredential(
modelHash,
operatorPrivateKey,
[Permission.READ_DATA, Permission.FINANCIAL_SMALL],
BigInt(Math.floor(Date.now() / 1000) + 86400), // expires in 24h
);
2. Registration: enrolling on-chain
The credential commitment is enrolled in the IdentityRegistry contract as a leaf in a Lean Incremental Merkle Tree (LeanIMT).
// Single enrollment
registry.enrollAgent(credential.commitment);
// Batch enrollment (gas efficient)
registry.enrollAgentBatch([cred1.commitment, cred2.commitment]);
Enrollment inserts the commitment into the agent identity registry (agentTree), computes a new Merkle root, and stores it in a 30-root history buffer. The buffer is root-count-based, not time-based: old roots are evicted when 30 newer roots are recorded, which could take seconds or days depending on enrollment frequency. This allows in-flight proofs generated against a recent root to remain valid.
The contract stores commitments (Merkle leaves), not credentials. The credential itself lives off-chain: in a database, an API, or a peer-to-peer exchange. The registry is a commitment ledger, not a credential database.
3. Resolution: looking up credentials
When a proof bundle arrives, the verifier extracts credentialCommitment and calls resolveCredential(commitment) to retrieve the full credential object. Two patterns:
In-memory store (demos and single-process servers):
class InMemoryCredentialStore {
private credentials = new Map<string, AgentCredential>();
async resolve(commitment: string): Promise<AgentCredential | null> {
return this.credentials.get(commitment) ?? null;
}
}
Registry API (production, multi-instance):
import { createRegistryResolver } from '@bolyra/sdk';
const resolve = createRegistryResolver({
registryUrl: 'https://registry.example.com',
});
// Fetches GET /v1/credentials/{commitment}
Resolution is off-chain and application-layer. The on-chain registry does not expose credential retrieval. It only validates Merkle proofs.
4. Expiry: time-bound enforcement
Expiry is enforced at two levels:
In the circuit (AgentPolicy.circom):
// Circom constraint — proof generation fails if expired
component notExpired = LessThan(64);
notExpired.in[0] <== currentTimestamp;
notExpired.in[1] <== expiryTimestamp;
notExpired.out === 1;
The circuit rejects any witness where currentTimestamp >= expiryTimestamp. A valid Groth16 proof cannot be generated for an expired credential. currentTimestamp is a public signal, so the on-chain verifier can check it against block.timestamp.
On-chain (IdentityRegistry.sol):
The contract also enforces a 5-minute freshness window: the proof's currentTimestamp must be within 5 minutes of block.timestamp. This prevents replaying proofs generated hours ago with a not-yet-expired credential.
5. Credential revocation
Two revocation paths, with different invalidation semantics:
Agent credential revocation (eventual): the commitment leaf is updated to 0 in the Merkle tree. A new root is computed and added to the 30-root history buffer. Proofs generated against the old root remain valid until 30 newer roots push it out of the buffer. This is root-count-based, not time-based: if enrollments are frequent, the old root expires quickly; if the tree is quiet, it persists longer.
// On-chain: zero the commitment leaf (onlyOwner)
registry.revokeAgent(oldCommitment, siblingNodes);
// agentTree._update(oldCommitment, 0, siblingNodes)
// New root computed, old root valid until evicted from 30-root buffer
This means credential revocation is eventual, not immediate. In-flight proofs generated before revocation may still verify against a buffered root. Once the old root is evicted, all proofs referencing the revoked credential fail.
Human revocation (immediate): the human's nullifier is added to a revocation mapping. The contract checks this mapping on every handshake verification, independent of Merkle roots. This is a hard revocation, not eventual.
// On-chain: mark nullifier as revoked (onlyOwner)
registry.revokeHuman(nullifier);
// humanRevocations[nullifier] = true
// Checked at verification: if (humanRevocations[nullifier]) revert;
Agent revocation is per-credential and eventual (root churn). Human revocation is per-identity and immediate (explicit check). Both require onlyOwner access on the contract.
6. Rotation: recovering from compromise
There is no built-in rotation circuit. Compromise is handled via revoke-and-re-enroll:
- Revoke the compromised credential:
revokeAgent(oldCommitment, siblingNodes) - Generate a new key pair
- Issue a new credential with the new operator key:
createAgentCredential(modelHash, newKey, permissions, expiry) - Enroll the new credential:
enrollAgent(newCommitment) - Update the credential store so
resolveCredentialreturns the new credential
The old and new credentials are not cryptographically linked. Clients must be updated with the new commitment. This is a known limitation. A future rotation circuit could prove that a new credential was authorized by the old key, enabling seamless rotation without requiring clients to update.
7. Delegated authorization in chains
Delegation extends the credential lifecycle. A delegator agent narrows its scope and passes a subset to a delegatee agent via delegated authorization. The Delegation circuit enforces two invariants:
delegateeScope & ~delegatorScope == 0(permissions are a subset)delegateeExpiry <= delegatorExpiry(expiry is narrowed, never extended)
A delegated credential cannot outlive or exceed its parent. If the parent credential is revoked and its old root is evicted from the 30-root buffer, the parent's Merkle proof fails and the delegation chain breaks. Until that eviction, in-flight delegated proofs may still verify. Max 3 hops per session (enforced by the contract via delegationHopCount tracking).
Summary
| Phase | Mechanism | Enforcement |
|---|---|---|
| Issuance | Poseidon5 commitment + EdDSA signature | SDK validation + circuit-enforced signature |
| Registration | Commitment enrolled in LeanIMT | On-chain contract |
| Resolution | Off-chain lookup (API or store) | Application layer |
| Expiry | Circuit constraint + 5-min freshness | Circom + Solidity |
| Revocation | Tree leaf zeroed, eventual (agent) or nullifier marked, immediate (human) | On-chain contract |
| Rotation | Revoke old, issue new, re-enroll | Sequential (no atomic circuit yet) |
| Delegation | Scope narrowing, expiry narrowing, max 3 hops | Circom + Solidity |
What this does not cover
This post describes the current implementation. Known gaps:
- Atomic rotation: no circuit proves that a new credential was authorized by the old key. Rotation requires two transactions and client updates.
- Delegation revocation propagation: revoking a delegator does not atomically revoke delegatees. Delegatees become unusable because the parent's Merkle proof fails, but the delegatee's commitment remains in the tree.
- Credential discovery: there is no standard way to discover which credentials exist for a given operator or model. Resolution requires knowing the commitment in advance.
← Previous: Protecting Robinhood's Agentic Trading MCP with Verifiable Agent Identity
Read the source: identity issuance, on-chain registry, and delegation circuits.
View on GitHub