Delegation Receipt Standard
Research Project
DRS is a JWT-based delegation receipt standard for MCP and OAuth-oriented agent ecosystems.
Every time an AI agent acts on your behalf, DRS produces a cryptographically signed receipt that proves — to anyone, without contacting a central authority — exactly who authorised what, under which constraints, at what time.
The problem DRS solves
Modern AI agents act through delegation chains. A human authorises an agent, which authorises a sub-agent, which calls a tool. Existing OAuth and token-exchange standards help frame that ecosystem, but the implemented DRS code here adds its own signed receipt chain so a tool server can independently verify provenance instead of trusting logs or bearer-token context alone.
This is the chain splicing vulnerability (CVE-2025-55241, demonstrated in Azure AD). DRS is designed as a receipt-layer response for that class of problem, implemented here as signed JWT receipts plus hash-chain verification.
What DRS is not
| DRS is | DRS is not |
|---|---|
| A receipt standard for delegation chains | A replacement for OAuth 2.1 |
| Built on JWTs, JCS, Ed25519, MCP middleware, and DIDs | A UCAN implementation or OAuth server |
| Independently verifiable audit evidence | An observability tool (Langfuse/Arize do that) |
| An open standard, not a platform | A blockchain product |
| The authorisation provenance layer | A replacement for OpenTelemetry |
Who this is for
- Builders — building on top of DRS: a React Native app, MCP server, A2A agent, Node backend, or any product that should carry signed delegation receipts. Start here if you're not sure. You never need to fork the repo.
- Developers — using the SDK or Go middleware programmatically (lower-level patterns than the consumer guides).
- Operators — deploying the verification server and configuring enterprise policies.
- Auditors — reconstructing delegation chains for compliance evidence.
- Contributors — understanding the architecture and proposing changes.
The three layers (published separately)
Each layer is an independently-installable artifact. Consumers pull from registries — no fork required.
| Component | Language | Install | Role |
|---|---|---|---|
drs-core | Rust | cargo add drs-core (or bundled inside @okeyamy/drs-sdk) | Crypto primitives, JCS canonicalisation, chain verification, WASM build |
drs-verify | Go | docker pull ghcr.io/okeyamy/drs-verify | Verification HTTP server, Go middleware, DID resolver, status list cache |
drs-sdk | TypeScript | pnpm add @okeyamy/drs-sdk | Issuance path, CLI, React Native / Node / browser |
This is a research project. The architecture, data model, and algorithms are documented throughout this site. The implementation is the reference implementation of the DRS 4.0 specification.
Start with What is DRS? for a conceptual overview, or jump straight to the Quick Start.
Quick Start
Get from zero to a verified bundle using the published SDK and verifier container. You do not need to clone this repository.
New to DRS? First read You do not need to fork and Which part of DRS do I install? to map your role to the right artifact.
Prerequisites
- Node.js 20+ and pnpm
- Docker (for the verifier). No Go toolchain required.
1. Install the SDK
pnpm add @okeyamy/drs-sdk
2. Generate a keypair
pnpm exec drs keygen
Current output is plaintext hex:
Ed25519 keypair generated.
DID : did:key:z6Mk...
Public key : <hex>
Private key : <hex>
Save the DID and private key securely.
3. Issue a root delegation
import { issueRootDelegation } from "@okeyamy/drs-sdk";
const privateKey = Uint8Array.from(Buffer.from("YOUR_PRIVATE_KEY_HEX", "hex"));
const now = Math.floor(Date.now() / 1000);
const rootDR = await issueRootDelegation({
signingKey: privateKey,
issuerDid: "did:key:z6MkYOUR_DID",
subjectDid: "did:key:z6MkYOUR_DID",
audienceDid: "did:key:z6MkAGENT_DID",
cmd: "/mcp/tools/call",
policy: {
allowed_tools: ["web_search"],
max_cost_usd: 10,
pii_access: false,
},
nbf: now,
exp: now + 3600,
rootType: "automated-system",
});
4. Start drs-verify (from the published image)
docker run --rm -d -p 8080:8080 --name drs-verify \
ghcr.io/okeyamy/drs-verify:latest
# Confirm it's up
curl http://localhost:8080/readyz
# {"status":"ready"}
No clone, no Go build — the image is published to GHCR from this repo's release pipeline.
5. Build and verify a bundle
import { createInvocationBundle, serialiseBundle } from "@okeyamy/drs-sdk";
import { writeFileSync } from "node:fs";
const bundle = await createInvocationBundle({
rootReceipt: rootDR,
signingKey: agentPrivateKey,
issuerDid: "did:key:z6MkAGENT_DID",
subjectDid: "did:key:z6MkYOUR_DID",
toolServer: "did:key:z6MkTOOL_DID",
tool: "web_search",
args: { query: "hello", estimated_cost_usd: 0.01 },
});
writeFileSync("bundle.json", serialiseBundle(bundle));
DRS_VERIFY_URL=http://localhost:8080 pnpm exec drs verify bundle.json
Expected successful output starts with:
✓ Chain verified
Root principal : did:key:z6Mk...
Chain depth : 1
Next steps
Pick your path:
- Building a mobile agent → React Native / Expo integration
- Building an MCP tool server in Node → MCP server integration (Node)
- Building an A2A agent in Node → A2A agent integration (Node)
- Building any other Node backend → Non-MCP Node backend integration
- Building in Go → MCP Middleware Integration (Go)
- Operating the verifier → Deploy drs-verify
- Reviewing evidence → Reconstruct a Chain
- Contributing a change → Dev Setup
What is DRS?
DRS (Delegation Receipt Standard) is a JWT-based, per-step delegation receipt standard for MCP and OAuth-oriented agent ecosystems.
It produces a cryptographically signed receipt at every step of an agent delegation chain so that any party — the tool server, a regulator, an auditor — can independently verify the complete provenance of any agent action without contacting a central authority.
The one-sentence definition
DRS adds a tamper-evident, independently verifiable receipt to every hop of an agent delegation chain.
What DRS adds around existing standards
OAuth / token exchange ecosystems → common surrounding auth context
DRS JWT receipts → signed proof for each delegation step
DRS verification → independent chain validation at the tool boundary
Without DRS, an audit trail exists only in server logs controlled by the operator. With DRS, the audit trail is in the receipts themselves — signed by the delegating party, verifiable by anyone with the public key.
The chain splicing problem
RFC 8693 allows Agent A to exchange its token for a new token representing Agent B acting on behalf of the original user. The problem: nothing prevents an attacker from splicing an unrelated token into the chain — presenting credentials from scope A while actually invoking scope B.
CVE-2025-55241 (Azure AD, March 2025) demonstrated this in production. The IETF OAuth WG's suggested mitigation #3 is per-step signed receipts. DRS is that mitigation.
How DRS works
- Delegation Receipt (DR): A signed JWT issued by each delegator. Contains the command, policy constraints, temporal bounds, and a SHA-256 hash of the previous DR.
- Chain: The linked sequence of DRs from the human root to the invoking agent. Each
prev_dr_hashfield links back, creating a tamper-evident chain. - Invocation Receipt: A signed JWT recording the actual tool call arguments, the full chain of DR hashes, and the tool server's DID.
- Bundle: The invocation receipt plus all DRs. On HTTP-terminated routes it
travels as a base64url-encoded JSON object in the
X-DRS-Bundleheader. On pure JSON-RPC MCP flows it can travel inparams._meta["X-DRS-Bundle"]with the same base64url encoding.
What DRS is not
DRS is frequently confused with systems it is adjacent to but distinct from:
| System | What it does | How it differs from DRS |
|---|---|---|
| OAuth 2.1 | Delegates access | DRS is designed to complement that ecosystem, but the implemented runtime here is JWT/JCS receipt verification |
| UCAN | Capability tokens (CBOR/IPLD) | DRS uses JWT receipts and DRS-specific fields, not UCAN envelopes |
| OpenTelemetry | Distributed tracing | Observability vs. authorisation provenance |
| Langfuse / Arize | LLM observability | Logs vs. cryptographic proofs |
| Agentic JWT | JWT profile for agent identity | Identity vs. delegation chain receipts |
| Blockchain audit logs | Immutable event log | DRS receipts work without blockchain (on-chain is optional Tier 5) |
For a detailed comparison, see DRS vs Alternatives.
Why DRS Exists
DRS exists because the AI agent ecosystem is deploying faster than the accountability infrastructure to support it.
The market reality
- 75% of C-suite executives rank auditability as their top AI governance requirement
- 82% of executives are confident in their AI oversight, but only 14.4% send agents to production with full approval chains
- Only 5.2% of enterprises have AI agents in production today — the accountability gap is the primary blocker
The question every CISO asks before approving an agent deployment: "If this agent does something it shouldn't, can we prove exactly who authorised it, and what they authorised?"
Server logs and bearer-token context alone cannot answer that question. DRS can.
The RFC 8693 gap
RFC 8693 (Token Exchange) defines how Agent A exchanges its bearer token for a new token representing Agent B acting on behalf of the user. This is the correct building block for agentic delegation.
The gap: RFC 8693 tokens are bearer tokens. Any agent that obtains a valid token can present it as if it were the legitimate holder. There is no per-step binding between the token and the specific delegation act that produced it.
Chain splicing: An attacker with access to one token can splice it into a different chain, presenting apparently legitimate credentials while exceeding the scope they were actually granted. CVE-2025-55241 (Azure AD, March 2025) is a real-world exploitation.
DRS closes this gap: the prev_dr_hash field in each receipt links it cryptographically to the previous one. Any substitution breaks the chain and fails Block B of verification.
Version history: what was tried
DRS reached v4 through three prior architectures that were each scrapped. Understanding what failed is essential for contributors — see False Positives: What We Tried for the full history.
v1 — Invented from scratch
Three fundamental errors:
- UCAN already defines delegation chains — v1 reinvented the wheel badly
- Applied a binary Merkle tree to a linear chain (CVE-2012-2459 risk)
- Under-specified security model
v1 was scrapped. The document is preserved in docs/DRS_architecture_v1.md.
v2 — UCAN profile (against wrong version)
Correctly identified DRS should be a UCAN Profile — but built against UCAN 0.x (JWT) while the actual spec was UCAN v1.0-rc.1 (CBOR/IPLD). Additional problems: TypeScript-only verification with V8 GC pauses destroying the <5ms latency requirement, unbounded DID resolver cache, status list race condition, O(n·m) policy check.
v2 was scrapped. The document is preserved in docs/Drs_architecture_v2.md.
v3/v4 — JWT-based DRS for OAuth/MCP ecosystems (current implementation)
The final pivot was away from UCAN envelopes and toward a JWT/JCS/Ed25519 implementation that fits the MCP and OAuth-oriented agent ecosystem. The reason was practical: AT Protocol, MCP, and the broader deployment environment already converge on JWT-based infrastructure, while UCAN adoption remained niche.
The current architecture separates concerns by language: Rust for crypto (zero GC), Go for verification middleware (goroutines), TypeScript for the developer SDK (npm ecosystem).
The Five Actors
DRS defines five actors. Every use case in this documentation maps to one or more of them.
1. End User (Human granting authority)
The human whose data or resources are at stake. They grant the initial delegation through a consent UI that translates policy into plain English.
What they see:
Research Agent wants permission to:
✓ Search the web
✓ Save files to your workspace
✗ Cannot access personal data
✗ Cannot spend more than £50.00
This permission lasts 30 days. Revoke it at any time.
The drs_consent field in the root DR records evidence of this consent: the method (explicit-ui-click), timestamp, session ID, and a SHA-256 hash of the human-readable text the user actually saw — not the machine-readable policy JSON.
Key concern: Did I actually authorise this specific action?
2. Developer
Integrates DRS into MCP tool servers or agent runtimes. Interacts with the TypeScript SDK (@okeyamy/drs-sdk) and the drs-verify HTTP API.
What they do:
- Call
issueRootDelegation/issueSubDelegationfrom the SDK - Add
X-DRS-Bundleheader to MCP requests - Deploy the Go middleware in front of their tool server
- Use the CLI (
drs verify,drs audit,drs policy) for debugging
Key concern: How do I integrate this in a day?
3. Agent Runtime
The automated system that acts on the user's behalf. Can be a single agent or a chain of agents delegating to sub-agents.
What it does:
- Evaluates its policy before every action (POLA — principle of least authority)
- Issues sub-delegation receipts when delegating to sub-agents
- Escalates out-of-policy requests to a supervisor agent (not a human)
- Auto-renews delegations before expiry for machine-rooted standing delegations
- Stops immediately when any policy constraint is exceeded
Key concern: Can I take this action within my current delegation?
4. Tool Server (Operator)
Receives MCP requests with X-DRS-Bundle headers. Runs full chain verification before executing any tool. Deployed by the enterprise operator.
What it does:
- Extracts and parses the DRS bundle from the HTTP header
- Runs
verify_chain(Blocks A–F) on every request — fail-closed - Rejects requests with invalid bundles
- Rate-limits by
root_principal(not just agent DID) to prevent agent-churn bypass - Emits
drs:tool-callactivity events
Key concern: Is this agent authorised to call this tool with these arguments?
5. Auditor / Compliance Officer
Reconstructs delegation chains after the fact to produce evidence for regulators, legal proceedings, or internal investigation. Does not need operator cooperation.
What they do:
drs verify bundle.json
drs audit bundle.json
Key concern: Can I prove what happened to a standard that satisfies EU AI Act Article 12?
How the actors interact
End User ──grant──► Agent Runtime ──sub-delegate──► Sub-Agent
│
invoke tool
▼
Tool Server
(verify_chain)
│
emit event
▼
Auditor reads
evidence later
The Developer builds both the agent runtime and the tool server integrations. The Operator deploys and configures the verification infrastructure.
Data Model
DRS defines three JWT types and one bundle format. All JWTs use the {"alg":"EdDSA","typ":"JWT"} header and are canonicalised with RFC 8785 JCS before signing.
1. Delegation Receipt (DR)
A signed JWT issued by each delegator. The root DR is issued by the human (or automated operator). Sub-DRs are issued by each agent in the chain.
Root DR payload example
{
"aud": "did:key:z6MkAgent1...",
"cmd": "/mcp/tools/call",
"drs_consent": {
"locale": "en-GB",
"method": "explicit-ui-click",
"policy_hash": "sha256:abc123...",
"session_id": "sess:8f3a2b1c",
"timestamp": "2026-03-28T10:30:00Z"
},
"drs_regulatory": {
"frameworks": ["eu-ai-act-art13"],
"retention_days": 730,
"risk_level": "limited"
},
"drs_root_type": "human",
"drs_type": "delegation-receipt",
"drs_v": "4.0",
"exp": 1745592000,
"iat": 1743000000,
"iss": "did:key:z6MkHuman...",
"jti": "dr:8f3a2b1c-4d5e-4xxx-8b9c-0d1e2f3a4b5c",
"nbf": 1743000000,
"policy": {
"allowed_tools": ["web_search", "write_file"],
"max_calls": 100,
"max_cost_usd": 50.00,
"pii_access": false,
"write_access": false
},
"prev_dr_hash": null,
"sub": "did:key:z6MkHuman..."
}
Note: Keys are sorted by Unicode code point in the JWT payload (RFC 8785 JCS). This is not cosmetic — it ensures identical bytes for identical data across all implementations.
Sub-DR differences
Sub-DRs differ from root DRs in three ways:
isschanges to the delegating agent's DID (not the human)policymust be a strict subset of the parent's policy (POLA)prev_dr_hashcontainssha256:{hex of parent DR JWT bytes}instead of nulldrs_consentanddrs_root_typeare absent
Field reference
See JWT Fields Reference for the complete field table with types and constraints.
2. Invocation Receipt
Records the actual tool call. Issued by the agent making the call immediately before invoking the tool.
{
"args": {
"estimated_cost_usd": 0.02,
"query": "Monad TPS benchmarks",
"tool": "web_search"
},
"cmd": "/mcp/tools/call",
"dr_chain": ["sha256:abc123...", "sha256:def456..."],
"drs_type": "invocation-receipt",
"drs_v": "4.0",
"iat": 1743000300,
"iss": "did:key:z6MkAgent2...",
"jti": "inv:7h5c4d3e-2a3b-4c5d-6e7f-8a9b0c1d2e3f",
"sub": "did:key:z6MkHuman...",
"tool_server": "did:key:z6MkToolServer..."
}
The dr_chain array contains the sha256:{hex} hashes of every DR in the chain, in order from root (index 0) to most recent sub-DR.
3. DRS Bundle
The unit of transport. Transmitted as the X-DRS-Bundle HTTP header (base64url of the JSON):
{
"bundle_version": "4.0",
"invocation": "<invocation-receipt-jwt>",
"receipts": [
"<root-dr-jwt>",
"<sub-dr-jwt-1>",
"<sub-dr-jwt-2>"
]
}
The receipts array is ordered from root (index 0) to most recent sub-delegation.
Chain hash computation
prev_dr_hash = "sha256:" + lowercase_hex(SHA-256(UTF-8 bytes of previous DR JWT string))
The hash is computed over the raw JWT string (the header.payload.signature format), not just the payload.
// TypeScript (drs-sdk)
function computeChainHash(jwt: string): string {
const bytes = new TextEncoder().encode(jwt);
const digest = sha256(bytes);
return 'sha256:' + Array.from(digest)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
#![allow(unused)] fn main() { // Rust (drs-core) pub fn compute_chain_hash(jwt: &str) -> String { let digest = sha2::Sha256::digest(jwt.as_bytes()); format!("sha256:{}", hex::encode(digest)) } }
Both must produce identical output for the same JWT string. This is verified in the cross-implementation test suite.
Verification Algorithm
verify_chain runs six sequential blocks (A–F). The function is fail-closed: any error in any block immediately returns an error without evaluating subsequent blocks.
The six blocks
Block A — Completeness
What: Bundle has at least one delegation receipt and exactly one invocation receipt.
Fail condition: receipts array is empty, or invocation field is missing or null.
Block B — Structural Integrity
What: The delegation receipts form a valid tamper-evident chain.
For each receipt at index i:
receipts[i].audmust equalreceipts[i+1].iss(audience of each DR is the issuer of the next)receipts[i+1].prev_dr_hashmust equalsha256:{SHA-256 of receipts[i] JWT bytes}- The invocation's
dr_chain[i]must equalsha256:{SHA-256 of receipts[i] JWT bytes}
Fail condition: Any hash mismatch, any issuer/audience gap, any missing dr_chain entry.
This block defeats chain splicing: substituting any DR changes its bytes, which changes its hash, which breaks prev_dr_hash in the next DR.
Block C — Cryptographic Validity
What: Every JWT signature is valid.
For each JWT in the bundle:
- Parse JWT header — must be
{"alg":"EdDSA","typ":"JWT"} - Resolve the issuer DID to its Ed25519 public key (LRU-cached)
- Verify the Ed25519 signature over
base64url(header).base64url(payload)
Fail condition: any signature invalid or any DID unresolvable.
Security: The multicodec prefix check when resolving
did:keyDIDs usescrypto/subtle.ConstantTimeComparein Go andsubtle::ConstantTimeEqin Rust. Usingbytes.Equalor==leaks timing information.
Block D — Semantic / Policy Compliance
What: The invocation arguments comply with every policy in the chain, and no sub-DR escalates beyond its parent.
Policies are evaluated conjunctively — all policies must pass:
args.toolmust be inpolicy.allowed_tools(if set) at every levelargs.estimated_cost_usdmust be ≤policy.max_cost_usd(if set) at every levelargs.pii_accessmust befalseifpolicy.pii_accessisfalseat any level
Sub-DR attenuation and temporal nesting checks:
- Each sub-DR's
policymust be a subset of its parent'spolicy - Child
nbfmust not be earlier than parentnbf - Child
expmust not outlive parentexpwhen both are set - Any escalation (wider tool list, higher cost limit,
pii_access: truewhen parent hasfalse) fails this block
Fail condition: Any argument exceeds any policy constraint, or any sub-DR escalates permissions.
Block E — Temporal Validity
What: All receipts are valid at the current time, and temporal bounds are properly nested.
now ≥ receipt.nbffor every receiptnow ≤ receipt.expfor every receipt whereexpis not null
Fail condition: any receipt is expired or not yet valid.
Block F — Revocation
What: No receipt has been revoked via either the remote Bitstring Status List or the local revocation store.
For each delegation receipt with a drs_status_list_index:
- Remote check — only if
STATUS_LIST_BASE_URLis configured. - Local check — query the in-memory local revocation store.
If the remote status cache is not configured, that part is skipped. Local revocation still runs.
Fail condition: any receipt is marked revoked, or a configured remote revocation check errors.
The
sync.Onceguard prevents double-fetch race conditions: when the remote cache expires and multiple goroutines arrive simultaneously, only one HTTP request is made — all others wait and reuse the result.
Algorithm pseudocode
verify_chain(bundle) → Result<VerifiedChain, VerifyError>:
# Block A
if bundle.receipts is empty: return Err(BUNDLE_INCOMPLETE)
if bundle.invocation is null: return Err(BUNDLE_INCOMPLETE)
drs = [decode_jwt(r) for r in bundle.receipts]
inv = decode_jwt(bundle.invocation)
# Block B
for i in 0..len(drs)-1:
if drs[i].aud != drs[i+1].iss: return Err(ISSUER_AUDIENCE_GAP)
if drs[i+1].prev_dr_hash != sha256(bundle.receipts[i]): return Err(CHAIN_HASH_MISMATCH)
for i, dr in enumerate(drs):
if inv.dr_chain[i] != sha256(bundle.receipts[i]): return Err(CHAIN_HASH_MISMATCH)
# Block C
for (jwt, payload) in receipts + invocation:
pub_key = resolve_did(payload.iss) # LRU cached, constant-time prefix check
if not ed25519_verify(jwt, pub_key): return Err(SIGNATURE_INVALID)
# Block D
for dr in drs:
if not args_satisfy_policy(inv.args, dr.policy): return Err(POLICY_VIOLATION)
for i in 1..len(drs):
if not is_attenuated_subset(drs[i].policy, drs[i-1].policy): return Err(POLICY_ESCALATION)
# Block E
now = unix_timestamp()
for dr in drs:
if now < dr.nbf: return Err(RECEIPT_NOT_YET_VALID)
if dr.exp != null and now > dr.exp: return Err(RECEIPT_EXPIRED)
# Block F
for dr in drs:
if dr.drs_status_list_index != null:
if remote_status_list_configured and remote_status_list.is_revoked(dr.drs_status_list_index):
return Err(RECEIPT_REVOKED)
if local_revocation_store.is_revoked(dr.drs_status_list_index):
return Err(RECEIPT_REVOKED)
return Ok(VerifiedChain{root_principal, subject, chain_depth, policy_result})
Performance targets
At 10,000 requests/second on the Go verification server:
| Operation | Cost | Notes |
|---|---|---|
| Policy check per level | O(1) avg | Hash-set intersection in capability index |
| DID resolution | O(1) amortised | LRU cache, 10,000 entry cap, 1-hour TTL |
| Status list check | O(1) amortised | 5-min TTL, sync.Once guard |
| Ed25519 verify | implementation-dependent | Go uses crypto/ed25519 |
| Total per request (2-hop chain) | ~0.8ms p99 |
Architecture
DRS uses a three-language stack. The three layers are peer implementations with different primary roles; the Go verifier does not call Rust at runtime.
The three layers
┌──────────────────────────────────────────────┐
│ TypeScript SDK (@okeyamy/drs-sdk) │
│ issuance, bundle assembly, CLI │
│ optional HTTP verification client │
└───────────────────┬──────────────────────────┘
│ optional WASM for browser/runtime use
│ HTTP to drs-verify
┌───────────────────▼──────────────────────────┐
│ Go verifier (drs-verify) │
│ verification server, middleware, revocation │
│ resolver cache, health/readiness, storage │
└───────────────────┬──────────────────────────┘
│ shared protocol contract
│ conformance vectors
┌───────────────────▼──────────────────────────┐
│ Rust core (drs-core) │
│ crypto primitives, JCS, chain hash, policy │
│ reference implementation for ambiguous cases │
└──────────────────────────────────────────────┘
Why Rust for the core
Rust is the lowest-level implementation and the internal reference when a conformance vector is ambiguous. It provides:
ed25519-dalek 2.xfor strict cryptographic operationsserde-json-canonicalizerfor RFC 8785 JCS- deterministic, low-level primitives suitable for WASM export
Rust is important for protocol correctness, but it is not linked into
drs-verify through CGO.
Why Go for verification
The Go service is the production verification path today. It handles:
verify.Chain()(Blocks A-F)MCPMiddleware/A2AMiddleware- DID resolution with LRU caching
- Bitstring Status List caching with
sync.Once - health and readiness endpoints
- storage and local revocation
Key implementation details:
crypto/ed25519for signature verificationcrypto/subtle.ConstantTimeComparefor DID multicodec prefix checksCGO_ENABLED=0 go buildfor a single static binary
Why TypeScript for the SDK
Issuance is developer-facing and low-frequency. TypeScript provides:
- ergonomic npm distribution:
pnpm add @okeyamy/drs-sdk - strong typing for policies, receipts, and bundles
- browser-friendly UI integration for consent flows
- the CLI used for local development and testing
The SDK also includes VerifyClient, which sends bundles to a running
drs-verify instance over HTTP. Local WASM verification exists as a separate,
explicit capability; it is not an automatic fallback inside VerifyClient.
JCS canonicalisation
All signed JSON in DRS is canonicalised with RFC 8785 before signing. The rules are:
- object keys sorted recursively
- no insignificant whitespace
- canonical JSON number formatting
// WRONG
const payload = JSON.stringify(obj);
// CORRECT
const payload = jcsSerialise(obj);
In the TypeScript SDK, jcsSerialise lives in drs-sdk/src/sdk/jcs.ts. The
Rust and TypeScript outputs are checked against shared conformance vectors.
WASM build
cd drs-core
wasm-pack build --target web --features wasm
# Output: drs-core/pkg/
The browser/WASM path is explicit: callers initialize it themselves via the
loader in drs-sdk/src/wasm/loader.ts.
Security Model
Threat table
| Threat | Attack vector | DRS mitigation | Residual risk |
|---|---|---|---|
| Forged root DR | Attacker creates a fake delegation | Ed25519 EUF-CMA: forgery requires solving the discrete log | Private key theft |
| Chain splicing | Compromised agent substitutes unrelated token | prev_dr_hash: any substitution changes the hash chain — fails Block B | Implementation bugs in hash computation |
| Policy escalation | Sub-DR claims wider permissions than parent | check_policy_attenuation() at issuance + Block D at verification | Policy schema gaps |
| Policy violation | Agent passes arguments exceeding constraints | Block D evaluates all policies conjunctively | Unlisted policy fields are not checked |
| DR tampering | Attacker modifies a signed DR | Ed25519 signature fails — fails Block C | None — structural |
| Chain injection | Insert a fake intermediate DR | prev_dr_hash changes break subsequent links — fails Block B | None — structural |
| Replay after revocation | Agent replays a revoked DR | Block F: Bitstring Status List (5-min cache TTL) | Up to 5-minute stale cache window |
| JSON malleability | Different canonical bytes for same logical JSON | RFC 8785 JCS enforced at both issuance and verification ends | Non-conforming JCS at one end |
| Signature malleability | (R, S) and (R, S+L) both verify under naive check | ed25519-dalek 2.x enforces S < L via verify_strict() | None — library enforces |
| DID spoofing | Attacker impersonates a legitimate issuer | did:key DIDs are derived from the public key — impossible without the private key | did:web requires DNS/TLS security |
| Prompt injection | Attacker embeds instructions in tool content | DRS records every invocation chain | Out of scope — model/runtime responsibility |
| Model-level bypass | Adversarial prompts bypass safety constraints | Model safety ≠ execution safety | Entirely outside DRS scope |
Fail-closed principle
DRS verification is fail-closed. Any error in any block returns an error and rejects the request. This applies to:
- Unresolvable DIDs
- Malformed JWTs
- Network errors fetching the Bitstring Status List
- Policy fields the verifier does not recognise
A partially valid chain is an invalid chain.
Constant-time operations
All security-sensitive comparisons use constant-time equality to prevent timing side-channels:
| Language | Safe | Unsafe |
|---|---|---|
| Go | crypto/subtle.ConstantTimeCompare | bytes.Equal, == |
| Rust | subtle::ConstantTimeEq | == on byte slices |
This applies specifically to multicodec prefix checks when resolving did:key DIDs. The two-byte prefix [0xed, 0x01] identifies an Ed25519 key. Checking it with a short-circuit comparison leaks timing information about where the mismatch occurs.
Key management
| Key type | Recommended storage | Rotation |
|---|---|---|
| Human root key | Hardware Security Module or Secure Enclave | Not rotated (DID is derived from key) |
| Operator root key | HSM required for production | Annual with overlap period |
| Agent session key | Ephemeral — generated per session | Per-session, never persisted |
did:key is the preferred DID method: the DID encodes the public key directly. No registry, no DNS, no trust anchor beyond the key itself. did:web is supported but requires DNS and TLS security.
What DRS does not protect against
- Prompt injection: An attacker embedding instructions in tool output or environment data. This is a model-layer problem. DRS records that the invocation happened and under what authorisation — it does not prevent the model from following injected instructions.
- Key compromise: If a private key is stolen, the attacker can forge receipts signed by that key. Mitigation: rotate keys, revoke outstanding delegations.
- Post-compromise recovery: DRS does not define how to recover a system after key material is compromised. That is an operational problem.
Regulatory Alignment
DRS is designed to produce evidence that satisfies the specific record-keeping requirements of AI governance regulations. The receipts are cryptographically signed and independently verifiable — an auditor does not need operator cooperation to authenticate the evidence.
EU AI Act
Article 12 — Record-keeping for high-risk AI systems
Article 12 requires high-risk AI systems to automatically log events with sufficient detail to enable post-market monitoring and investigation. DRS Delegation Receipts satisfy this:
- Tamper-evident: Ed25519 signatures; any modification breaks verification
- Independently verifiable: Public keys are encoded in the DID — no central authority needed
- Comprehensive: Every delegation hop and every invocation is receipted
Article 13 — Transparency
Article 13 requires transparency in the operation of high-risk AI systems. DRS provides:
- Human-readable policy translation at the point of consent (the
drs_consent.policy_hashcovers the text the user saw — not just the machine-readable JSON) - Complete chain reconstruction without operator involvement
- Per-invocation records linking every agent action to the authorising human
Current state: there is no dedicated EU AI Act export command in the CLI
yet. Today, evidence is assembled from bundle.json, drs verify, and
drs audit output.
HIPAA §164.312(b) — Audit Controls
For healthcare deployments handling PHI, HIPAA §164.312(b) requires audit controls that record and examine activity. DRS provides:
- Invocation Receipts recording every agent action with full delegation provenance
- Signed proof that access was authorised before it occurred (not just a log that it happened)
- Tier 3 / Tier 4 deployment postures with timestamping support
AIUC-1 Certification
AIUC (AI Underwriting Company, founded July 2025 with $15M seed) certifies AI systems for insurance underwriting. AIUC-1 requires demonstrable proof of authorisation for every agent action — not just server logs.
The AIUC-1 requirement: "For any agent action, provide cryptographic proof that the action was within the scope of an authorisation granted by an identifiable principal."
DRS Delegation Receipts satisfy this directly. AIUC-1 is identified as the primary near-term commercial opportunity for DRS-based deployments.
SOC 2 Type II
SOC 2 requires continuous evidence of access controls. DRS provides:
- Signed receipts for every delegation grant (who authorised what, when, with what constraints)
- Tamper-evident chain linking every action to its authorisation
- Revocation mechanism for compromised keys
FINOS AI Governance Framework
FINOS Tier 3–4 levels require chain-of-custody evidence admissible in legal proceedings. DRS Delegation Receipts are:
- Based on open standards (Ed25519, JWT, RFC 8785 JCS) and designed for OAuth-oriented ecosystems — no proprietary formats
- Independently verifiable — no vendor lock-in for evidence authentication
- Exportable in structured formats
Relevant financial regulations: SR 11-7 (Federal Reserve model risk management), EBA Guidelines on ICT risk, GDPR Article 22 (automated decision-making explainability), MiFID II audit trails.
Storage tiers and retention
| Tier | storage_tier | Backend | Status |
|---|---|---|---|
| Session | 0 | In-memory | Implemented |
| Ephemeral | 1 | Local filesystem | Implemented |
| Durable | 2 | S3-compatible object store | Roadmap |
| Compliant | 3 | Filesystem + RFC 3161 timestamping | Partial |
| Timestamped | 4 | Tier 3 deployment posture | Partial |
| On-chain | 5 | Ethereum anchor | Roadmap |
Configure via storage_tier in the Operator Configuration.
Tutorial: Issue Your First Delegation
This tutorial walks through issuing a root delegation receipt and a sub-delegation from scratch using the TypeScript SDK. You will end up with two signed JWTs linked by prev_dr_hash.
Prerequisites
- Node.js 20+ and pnpm
@okeyamy/drs-sdkinstalled:pnpm add @okeyamy/drs-sdk
Step 1: Generate two keypairs
# Human keypair
pnpm exec drs keygen
# Private key: <HUMAN_PRIVATE_KEY>
# DID: did:key:z6MkHUMAN...
# Agent keypair
pnpm exec drs keygen
# Private key: <AGENT_PRIVATE_KEY>
# DID: did:key:z6MkAGENT...
Step 2: Issue the root delegation
Create issue-demo.ts:
import { issueRootDelegation, computeChainHash } from '@okeyamy/drs-sdk';
const humanKey = Uint8Array.from(Buffer.from('HUMAN_PRIVATE_KEY_HEX', 'hex'));
const now = Math.floor(Date.now() / 1000);
const rootDR = await issueRootDelegation({
signingKey: humanKey,
issuerDid: 'did:key:z6MkHUMAN...',
subjectDid: 'did:key:z6MkHUMAN...', // human is both issuer and subject at root
audienceDid: 'did:key:z6MkAGENT...',
cmd: '/mcp/tools/call',
policy: {
allowed_tools: ['web_search', 'write_file'],
max_cost_usd: 50.00,
pii_access: false,
write_access: false,
},
nbf: now,
exp: now + 86400, // 24 hours
rootType: 'human',
consent: {
method: 'explicit-ui-click',
timestamp: new Date().toISOString(),
session_id: 'sess:' + crypto.randomUUID(),
policy_hash: 'sha256:placeholder', // In production: sha256 of human-readable text
locale: 'en-GB',
},
});
console.log('Root DR JWT:', rootDR);
console.log('Root DR hash:', computeChainHash(rootDR));
Run it:
pnpm exec tsx issue-demo.ts
Step 3: Issue a sub-delegation
The agent narrows the policy before delegating further:
import { issueSubDelegation } from '@okeyamy/drs-sdk';
const agentKey = Uint8Array.from(Buffer.from('AGENT_PRIVATE_KEY_HEX', 'hex'));
const parentPolicy = {
allowed_tools: ['web_search', 'write_file'],
max_cost_usd: 50.00,
pii_access: false,
write_access: false,
};
const subDR = await issueSubDelegation({
signingKey: agentKey,
issuerDid: 'did:key:z6MkAGENT...',
subjectDid: 'did:key:z6MkHUMAN...', // subject never changes
audienceDid: 'did:key:z6MkSUBAGENT...',
cmd: '/mcp/tools/call',
policy: {
allowed_tools: ['web_search'], // tightened: removed write_file
max_cost_usd: 5.00, // tightened: £50 → £5
pii_access: false,
write_access: false,
},
nbf: now,
exp: now + 3600, // 1 hour (shorter than parent's 24 hours)
parentJwt: rootDR,
parentPolicy: parentPolicy,
parentNbf: now,
parentExp: now + 86400,
});
console.log('Sub-DR JWT:', subDR);
What you built
You now have two JWTs where:
subDRpayload contains"prev_dr_hash": "sha256:{hash of rootDR}"- The policy in
subDRis strictly contained withinrootDR's policy - Any tampering with
rootDRbreaks the hash chain — fails Block B of verification
What happens if you try to escalate?
Try setting max_cost_usd: 100 in the sub-delegation (exceeds the parent's 50):
DrsError: POLICY_ESCALATION — max_cost_usd 100 exceeds parent limit 50
The error fires at issuance — before any signing occurs. You cannot accidentally create an invalid chain.
Next steps
- Tutorial: Verify a Bundle — verify the chain you just built
- Sub-Delegation how-to — detailed attenuation rules
Tutorial: Verify a Bundle
This tutorial builds on Issue Your First Delegation. You will wrap the delegation chain in a bundle, send it to drs-verify, and confirm it passes all six verification blocks.
Prerequisites
- Go 1.22+ with
drs-verifysource - The
rootDRandsubDRJWTs from Tutorial 1 @okeyamy/drs-sdkinstalled
Step 1: Start drs-verify
cd drs-verify
go run ./cmd/server
# drs-verify listening on :8080
Step 2: Issue an invocation receipt
import { issueInvocation, computeChainHash, buildBundle, serialiseBundle } from '@okeyamy/drs-sdk';
import { writeFileSync } from 'fs';
const agentKey = Uint8Array.from(Buffer.from('SUBAGENT_PRIVATE_KEY_HEX', 'hex'));
const invocation = await issueInvocation({
signingKey: agentKey,
issuerDid: 'did:key:z6MkSUBAGENT...',
subjectDid: 'did:key:z6MkHUMAN...',
cmd: '/mcp/tools/call',
args: {
tool: 'web_search',
query: 'Monad TPS benchmarks',
estimated_cost_usd: 0.02,
},
drChain: [
computeChainHash(rootDR),
computeChainHash(subDR),
],
toolServer: 'did:key:z6MkTOOLSERVER...',
});
// Build and serialise the bundle
const bundle = buildBundle({
invocation,
receipts: [rootDR, subDR],
});
writeFileSync('bundle.json', serialiseBundle(bundle));
console.log('Bundle written to bundle.json');
Step 3: Verify via CLI
DRS_VERIFY_URL=http://localhost:8080 pnpm exec drs verify bundle.json
Expected output begins with:
✓ Chain verified
Root principal : did:key:z6MkHUMAN...
Chain depth : 2
Step 4: Verify via HTTP API directly
curl -s -X POST http://localhost:8080/verify \
-H "Content-Type: application/json" \
-d @bundle.json | jq .
{
"valid": true,
"context": {
"root_principal": "did:key:z6MkHUMAN...",
"chain_depth": 2,
"root_type": "human"
}
}
Step 5: Test a rejection
Tamper with the bundle — modify one character in rootDR:
# Create a tampered bundle
cat bundle.json | sed 's/"receipts":\["eyJ/\"receipts\":[\"fakeXXX/' > tampered.json
DRS_VERIFY_URL=http://localhost:8080 pnpm exec drs verify tampered.json
Expected:
✗ Verification failed
Code : CHAIN_HASH_MISMATCH
Step 6: Print the full audit trail
pnpm exec drs audit bundle.json
This prints the current compact audit breakdown: bundle version, receipt count, key receipt fields, and the invocation's issuer / command / tool server.
Next steps
- End-to-End Trace — watch the full lifecycle including tool server execution
- MCP Middleware Integration — integrate this verification flow into a real MCP server
Tutorial: End-to-End Trace
This tutorial traces one complete tool call through the full DRS lifecycle — from the human granting authority to the auditor reading the evidence. It covers every actor and every component.
The scenario
- Amara (End User): grants a research agent permission to use
web_search, spend up to £50 - Research Agent: delegates to a sub-agent with tighter constraints (£5,
web_searchonly) - Sub-Agent: calls
web_searchon the tool server - Tool Server: runs
verify_chainbefore executing - Auditor: reconstructs the chain afterwards
Step 1: Amara grants authority
Amara sees a consent UI on the developer's application:
Research Agent wants permission to:
✓ Search the web
✗ Cannot access personal data
✗ Cannot spend more than £50.00
This permission lasts 30 days. [Allow] [Deny]
Amara clicks Allow. The SDK issues the root DR:
Root DR JWT payload (keys sorted by JCS):
{
"aud": "did:key:z6MkAgent1...",
"cmd": "/mcp/tools/call",
"drs_consent": {
"locale": "en-GB",
"method": "explicit-ui-click",
"policy_hash": "sha256:a1b2c3...", ← SHA-256 of the text Amara saw
"session_id": "sess:abc-123",
"timestamp": "2026-03-28T10:30:00Z"
},
"drs_root_type": "human",
"drs_type": "delegation-receipt",
"drs_v": "4.0",
"exp": 1748437800,
"iat": 1743000000,
"iss": "did:key:z6MkAmara...",
"jti": "dr:8f3a2b1c-4d5e-4abc-8b9c-0d1e2f3a4b5c",
"nbf": 1743000000,
"policy": { "allowed_tools": ["web_search"], "max_cost_usd": 50 },
"prev_dr_hash": null,
"sub": "did:key:z6MkAmara..."
}
The JWT is signed with Amara's Ed25519 private key.
Step 2: Research Agent evaluates and sub-delegates
Before acting, the Research Agent evaluates the proposed web_search call against its current policy:
web_search∈allowed_tools✓estimated_cost: 0.02≤max_cost_usd: 50✓
The Research Agent decides to delegate to the Sub-Agent with tighter constraints (POLA):
Sub-DR JWT payload:
{
"aud": "did:key:z6MkAgent2...",
"cmd": "/mcp/tools/call",
"drs_type": "delegation-receipt",
"drs_v": "4.0",
"exp": 1743003600, ← 1 hour (shorter than Amara's 30 days)
"iat": 1743000010,
"iss": "did:key:z6MkAgent1...",
"jti": "dr:1a2b3c4d-5e6f-4xyz-9abc-def012345678",
"nbf": 1743000000,
"policy": { "allowed_tools": ["web_search"], "max_cost_usd": 5 },
"prev_dr_hash": "sha256:abc123...", ← SHA-256 of rootDR JWT bytes
"sub": "did:key:z6MkAmara..." ← unchanged
}
Step 3: Sub-Agent creates the invocation receipt
The Sub-Agent records the actual tool call:
Invocation Receipt JWT payload:
{
"args": {
"estimated_cost_usd": 0.02,
"query": "Monad TPS benchmarks",
"tool": "web_search"
},
"cmd": "/mcp/tools/call",
"dr_chain": [
"sha256:abc123...", ← SHA-256 of rootDR
"sha256:def456..." ← SHA-256 of subDR
],
"drs_type": "invocation-receipt",
"drs_v": "4.0",
"iat": 1743000300,
"iss": "did:key:z6MkAgent2...",
"jti": "inv:7h5c4d3e-2a3b-4c5d-6e7f-8a9b0c1d2e3f",
"sub": "did:key:z6MkAmara...",
"tool_server": "did:key:z6MkToolServer..."
}
Step 4: Tool server receives and verifies
The Sub-Agent sends:
POST /mcp/tools/call HTTP/1.1
Authorization: Bearer <oauth-token>
X-DRS-Bundle: eyJidW5kbGVfdmVyc2lvbiI6IjQuMCIsImludm9jYXRpb24i...
Content-Type: application/json
{"tool": "web_search", "query": "Monad TPS benchmarks"}
The MCP middleware runs verify_chain:
Block A: receipts=[rootDR, subDR], invocation present → PASS ✓
Block B: rootDR.aud == subDR.iss (z6MkAgent1) → PASS ✓
subDR.prev_dr_hash == sha256(rootDR) → PASS ✓
inv.dr_chain matches [sha256(rootDR), sha256(subDR)] → PASS ✓
Block C: Ed25519 verify rootDR (Amara's key) → PASS ✓
Ed25519 verify subDR (Agent1's key) → PASS ✓
Ed25519 verify invocation (Agent2's key) → PASS ✓
Block D: web_search ∈ allowed_tools at both DR levels → PASS ✓
0.02 ≤ 5 (subDR) ≤ 50 (rootDR) → PASS ✓
subDR policy ⊆ rootDR policy (attenuation check) → PASS ✓
Block E: now=1743000300 ∈ [1743000000, 1743003600] (subDR window) → PASS ✓
now=1743000300 ∈ [1743000000, 1748437800] (rootDR window) → PASS ✓
Block F: rootDR jti "dr:8f3a..." not in status list → PASS ✓
subDR jti "dr:1a2b..." not in status list → PASS ✓
RESULT: VALID — executing tool call
Step 5: Tool executes and emits event
The tool server runs web_search and emits:
{
"event": "drs:tool-call",
"root_principal": "did:key:z6MkAmara...",
"chain_depth": 2,
"command": "/mcp/tools/call",
"tool": "web_search",
"policy_result": "pass",
"cost_usd": 0.02,
"inv_jti": "inv:7h5c4d3e-..."
}
Amara's activity feed updates: "Research agent used web_search — £0.02 of £50.00 budget used."
Step 6: Auditor reconstructs the chain (later)
Three months later, a compliance officer wants evidence:
pnpm exec drs verify bundle.json
pnpm exec drs audit bundle.json
The compliance officer does not need to contact the operator. The evidence is in
the signed bundle plus the verifier output. The current drs audit command is
compact rather than full forensic export, but it still exposes the key receipt
and invocation fields.
You do not need to fork this repo
This page exists to remove a common confusion.
Consuming DRS means pulling from package registries. It does not mean cloning this repository into your source tree.
All three layers are published. Pick the ones that match your role and install them the way you'd install any other dependency.
| Layer | How builders get it | You edit this? |
|---|---|---|
drs-core | cargo add drs-core (or via WASM inside @okeyamy/drs-sdk) | No — unless you're contributing back |
drs-verify | docker pull ghcr.io/okeyamy/drs-verify:latest | No — it's a service, you run the image |
drs-sdk | pnpm add @okeyamy/drs-sdk | No — regular npm dependency |
You only clone the repo if you want to:
- contribute (fix a bug, propose a feature, submit a PR)
- build from source (e.g. for air-gapped deployments where pulling from Docker Hub / GHCR is not allowed)
- vendor a specific commit (hash-pin for supply-chain strictness)
"But where does my code go?"
Your application lives in your own repository. DRS is a dependency. The typical shape:
your-app/
├── package.json ← "@okeyamy/drs-sdk": "^0.1.0"
├── docker-compose.yml ← services: your-app, drs-verify
└── src/
├── issue-receipt.ts ← uses @okeyamy/drs-sdk
└── verify-middleware.ts ← calls http://drs-verify:8080/verify
You never add drs-core/, drs-verify/, or drs-sdk/ as subdirectories
of your repo.
Verification: the five-minute test
Run this on a fresh machine with Docker and Node 20:
# No `git clone` of the DRS monorepo — this stays empty.
mkdir my-drs-app && cd my-drs-app
# 1. Pull the SDK from npm.
echo '{"type":"module","dependencies":{"@okeyamy/drs-sdk":"latest"}}' > package.json
npm install
# 2. Pull the verifier from GHCR and run it.
docker run --rm -d -p 8080:8080 --name drs-v ghcr.io/okeyamy/drs-verify:latest
# 3. Prove the SDK works against the running verifier.
node -e '
import("@okeyamy/drs-sdk").then(async (s) => {
console.log("SDK version loaded:", typeof s.issueRootDelegation === "function" ? "ok" : "missing");
const res = await fetch("http://localhost:8080/healthz");
console.log("verifier healthz:", res.status, await res.json());
});
'
# 4. Stop the verifier.
docker stop drs-v
Expected output:
SDK version loaded: ok
verifier healthz: 200 { status: 'ok' }
No clone. No fork. Nothing about this machine knows about the DRS source tree.
When you should clone
Clone if:
- You're changing DRS itself. Crypto bug, new feature, new test vector — clone, fix, PR. See Contributing.
- You need reproducible builds with a commit hash. Pin to a commit, build the Docker image yourself in your CI, push to your own registry. Your base of trust moves from GHCR to your own build.
- You're running an air-gapped deployment. Build
drs-verifyfrom source, vendor the SDK into your npm mirror, run the container from your private registry.
In every other case — using DRS in your product, wiring it into MCP or A2A, issuing receipts from a React Native app — you install from the registries. No fork.
Which part of DRS do I install?
DRS has three separately-published layers. Which you install depends on what you are building. This page maps common roles to the artifact(s) you need.
One-minute decision tree
What are you building?
│
├─ An AI agent / client that ACTS on behalf of a user or service
│ → Install @okeyamy/drs-sdk (issuance path)
│
├─ A tool server or gateway that ACCEPTS requests from agents
│ → Run ghcr.io/okeyamy/drs-verify (verification service)
│ → OR embed pkg/middleware in your Go server
│
├─ A human-consent UI (user clicks "Approve", you mint a root delegation)
│ → Install @okeyamy/drs-sdk
│
├─ An auditor / compliance replay tool (verify chains after the fact)
│ → Install @okeyamy/drs-sdk (uses its VerifyClient)
│ → OR point it at a running drs-verify /verify endpoint
│
└─ Rust binary / WASM polyfill
→ Install drs-core from crates.io
Mapping roles to artifacts
Role: Agent runtime (Node, browser, React Native, Deno)
Install the SDK from npm.
pnpm add @okeyamy/drs-sdk
You use it to:
- generate keys (
drs keygenor programmatically) - issue root delegations (when a human consents)
- issue sub-delegations (when an agent delegates to another agent)
- issue invocations (when the agent actually calls a tool)
- optionally, verify bundles via
VerifyClient
You do not need to run drs-verify for issuance. Issuance is all
local cryptography.
Role: Tool server / MCP server / API gateway
Run the verification service. Two shapes:
Shape A — sidecar verifier (any language tool server)
Run ghcr.io/okeyamy/drs-verify:latest next to your tool server. In your
server's request handler, before doing real work, call
POST /verify with the incoming bundle. If result.valid is true, proceed.
┌─────────────────┐ ┌─────────────────┐
│ your tool │ POST │ drs-verify │
│ server (any │──/verify─▶ :8080 (sidecar) │
│ language) │ ◀─json─ │ │
└─────────────────┘ └─────────────────┘
Best for: Node, Python, Rust, Ruby, Java — anything not Go.
Shape B — embedded Go middleware
If your tool server is in Go, import the middleware package directly. Faster path (no extra hop), but Go-only.
import "github.com/drs-protocol/drs-verify/pkg/middleware"
mux.Handle("/tools/call", middleware.MCPMiddleware(deps, nonceStore, yourHandler))
Best for: Go MCP servers, Go A2A servers.
Role: Human-consent UI
Install the SDK from npm, same as an agent. The difference is semantic:
your app's issueRootDelegation call represents the moment a human
clicked "Approve". Capture consent metadata (session ID, policy hash,
timestamp) in the consent field.
Role: Auditor / compliance reviewer
Install the SDK and use its VerifyClient to replay past chains. You can
point it at a running drs-verify or use the SDK-only in-process
verifier for air-gapped replay.
pnpm add @okeyamy/drs-sdk
import { VerifyClient } from "@okeyamy/drs-sdk";
const client = new VerifyClient({ baseUrl: "https://drs-verify.internal" });
const result = await client.verify(bundle);
Role: Rust/WASM builder
Most Rust callers don't interact with drs-core directly — it's
embedded inside @okeyamy/drs-sdk via WASM. But if you're building a
Rust binary (for example, a CLI that issues receipts), use the crate:
[dependencies]
drs-core = "0.1"
Combining them
A typical production deployment uses all three:
┌──────────────────────┐
│ Agent (React Native) │ uses @okeyamy/drs-sdk (npm)
└──────────┬───────────┘
│ HTTPS: X-DRS-Bundle: <base64url>
▼
┌──────────────────────┐
│ Tool server (Node) │ forwards body + bundle
└──────────┬───────────┘
│ POST /verify
▼
┌──────────────────────┐
│ drs-verify (Docker) │ runs from ghcr.io/okeyamy/drs-verify
│ + Redis (replay) │
└──────────────────────┘
None of these three boxes clones the DRS monorepo.
Related
- You do not need to fork
- Integrate with MCP (Node)
- Integrate with React Native
- Integrate with a non-Go HTTP gateway
Integrate DRS in a React Native app
This guide covers installing and using @okeyamy/drs-sdk inside an Expo
or bare React Native app. The SDK is pure TypeScript plus pure-JS
cryptography (@noble/ed25519) — no native modules, no WASM glue code
required on the mobile side.
Compatibility matrix
| Runtime | Status |
|---|---|
| Expo SDK 50+ (managed) | Supported |
| Expo SDK 50+ (bare) | Supported |
| React Native 0.73+ (community CLI) | Supported |
| Hermes (default on RN 0.70+) | Supported |
| JavaScriptCore | Supported |
The SDK relies on:
crypto.getRandomValues— provided by React Native'sexpo-cryptoor by modern RN directly. Polyfill on older targets.TextEncoder/TextDecoder— provided by Hermes. On JSC, polyfill withtext-encoding.atob/btoa— provided by both engines.
Install
# Expo
npx expo install @okeyamy/drs-sdk
# RN community CLI
pnpm add @okeyamy/drs-sdk
If your project runs on an older RN (<0.74) or bare JSC:
pnpm add react-native-get-random-values text-encoding
Then import the polyfills once, at the top of your entry file
(index.js or App.tsx):
import "react-native-get-random-values";
import "text-encoding/encoding-indexes";
Generate and persist a key
Mobile apps typically generate a per-device key on first launch and
store it in the OS secure enclave / Keychain. Use
expo-secure-store (Expo) or react-native-keychain (bare).
import * as SecureStore from "expo-secure-store";
import { derivePublicKey } from "@okeyamy/drs-sdk";
export async function getOrCreateAgentKey(): Promise<Uint8Array> {
const existing = await SecureStore.getItemAsync("drs.agent_sk");
if (existing) {
return Uint8Array.from(Buffer.from(existing, "hex"));
}
// Fresh key: 32 bytes from a CSPRNG.
const sk = new Uint8Array(32);
globalThis.crypto.getRandomValues(sk);
await SecureStore.setItemAsync(
"drs.agent_sk",
Buffer.from(sk).toString("hex"),
{ keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY },
);
return sk;
}
export function didFromKey(sk: Uint8Array): string {
const pub = derivePublicKey(sk);
const multicodec = new Uint8Array([0xed, 0x01, ...pub]);
return `did:key:z${base58btc(multicodec)}`;
}
base58btc is a small pure-JS helper; see the
SDK tests
for an inlineable implementation.
Issue an invocation when the user taps a button
import { useState } from "react";
import { Button, Text } from "react-native";
import {
buildBundle,
issueInvocation,
computeChainHash,
serialiseBundle,
} from "@okeyamy/drs-sdk";
export function CallToolButton({
agentKey,
agentDid,
rootDR,
toolServerDid,
}: Props) {
const [result, setResult] = useState<string>("");
async function onCall() {
const invocation = await issueInvocation({
signingKey: agentKey,
issuerDid: agentDid,
subjectDid: agentDid,
cmd: "/mcp/tools/call",
args: { tool: "web_search", query: "react native drs integration" },
drChain: [computeChainHash(rootDR)],
toolServer: toolServerDid,
});
const bundle = buildBundle([rootDR], invocation);
const bundleHeader = serialiseBundle(bundle);
const res = await fetch("https://your-tool-server.example.com/mcp/tools/call", {
method: "POST",
headers: {
"content-type": "application/json",
"X-DRS-Bundle": bundleHeader,
},
body: JSON.stringify({ tool: "web_search", query: "..." }),
});
setResult(await res.text());
}
return (
<>
<Button onPress={onCall} title="Run tool with DRS receipt" />
<Text>{result}</Text>
</>
);
}
The root delegation (rootDR) was issued when the user approved the
agent — see Human Consent Records.
Persist it alongside the agent key, or fetch it from your backend on app
launch.
Where drs-verify runs
The verifier does not run on the phone. It is a backend service
that your tool server calls (or that your tool server is wrapped by).
From the React Native app's perspective, DRS is issuance-only: you sign
receipts, you send them over HTTPS in the X-DRS-Bundle header, the
server verifies.
A typical deployment:
┌───────────────────────┐
│ React Native app │ @okeyamy/drs-sdk (npm)
└─────────┬─────────────┘
│ HTTPS + X-DRS-Bundle header
▼
┌───────────────────────┐
│ Your tool server API │ validates bundle (sidecar or Go middleware)
└─────────┬─────────────┘
│ POST /verify (optional sidecar mode)
▼
┌───────────────────────┐
│ drs-verify (Docker) │ ghcr.io/okeyamy/drs-verify
└───────────────────────┘
See Integrate DRS with an MCP Node server for the server side.
Troubleshooting
"crypto.getRandomValues is not a function"
You're on an older RN without a secure random source. Install and import
react-native-get-random-values as shown above.
"Cannot find module '@noble/ed25519'"
This is a transitive dependency of the SDK. It should resolve automatically. If it doesn't, clear the Metro cache:
npx expo start --clear
# or for bare:
pnpm start -- --reset-cache
Signatures don't verify on the server
Double-check that drChain entries are chain hashes
(sha256:...), not raw JWTs. The SDK exposes computeChainHash(jwt)
for this — forgetting it is the most common mistake.
Bundle is too large for HTTP headers
Some gateways cap header size at 8 KB. If your delegation chain has many
sub-delegations, consider sending the bundle as a request body field
using the JSON-RPC _meta pattern instead of the X-DRS-Bundle
header. Both shapes are defined in
drs-source-of-truth.md.
Integrate DRS with an MCP server (Node / TypeScript)
Your MCP server runs on Node. Agents send tool-call requests with a
X-DRS-Bundle header. You want the bundle verified before your business
logic runs. This is the sidecar pattern.
No Go code, no forking DRS, no rebuilding containers.
Architecture
Agent (React Native, web, Node, etc.)
│
│ POST /tools/call
│ X-DRS-Bundle: eyJ...
│
▼
┌────────────────────────────┐ ┌───────────────────────┐
│ Your MCP server (Node) │──────▶│ drs-verify (Docker) │
│ 1. read bundle from header│ POST │ ghcr.io/okeyamy/ │
│ 2. POST /verify │ /verify│ drs-verify:latest │
│ 3. if valid → run tool │ │ │
│ 4. else → 403 │◀──────│ │
└────────────────────────────┘ └───────────────────────┘
Install the enforcement middleware
The secure default path is the reusable HTTP middleware from @drs/mcp-server.
It extracts X-DRS-Bundle, sends the decoded bundle plus the actual request body
to drs-verify, rejects invalid chains, rejects body-binding mismatches, and
only then lets your handler run.
# On your MCP server
pnpm add @drs/mcp-server
Docker Compose for local dev
# docker-compose.yml at the root of YOUR project
services:
mcp-server:
build: .
ports:
- "3000:3000"
environment:
DRS_VERIFY_URL: http://drs-verify:8080
depends_on:
- drs-verify
drs-verify:
image: ghcr.io/okeyamy/drs-verify:latest
environment:
LISTEN_ADDR: ":8080"
LOG_FORMAT: json
# Optional: replay protection that survives restart and scales horizontally
NONCE_STORE_BACKEND: redis
REDIS_URL: redis://redis:6379/0
depends_on:
- redis
redis:
image: redis:7-alpine
Middleware for your MCP server
Express / Fastify / raw http.Server — the pattern is the same.
// drs-middleware.ts
import { createDrsHttpMiddleware } from "@drs/mcp-server";
const VERIFY_URL = process.env.DRS_VERIFY_URL ?? "http://localhost:8080";
const drs = createDrsHttpMiddleware({ verifyUrl: VERIFY_URL });
export async function drsVerify(req, res, next) {
const result = await drs(
{
headers: req.headers,
body: req.body,
},
(verifiedReq) => {
req.drs = verifiedReq.drs;
next();
},
);
if (!result.ok) {
return res.status(result.status).json({ drs_error: result.error });
}
}
Wiring it in Express
import express from "express";
import { drsVerify } from "./drs-middleware.js";
const app = express();
app.use(express.json());
app.post("/tools/call", drsVerify, async (req, res) => {
// req.drs is set — it contains RootPrincipal, LeafPolicy, etc.
const { tool, ...args } = req.body;
// Enforce policy at the tool layer. `drs-verify` has already checked
// attenuation; here you enforce execution-time limits.
const maxCost = req.drs.leaf_policy?.max_cost_usd;
if (maxCost != null && args.estimated_cost_usd > maxCost) {
return res.status(403).json({ error: "Exceeds policy.max_cost_usd" });
}
const result = await runTool(tool, args);
res.json(result);
});
app.listen(3000);
Wiring it in Fastify
import Fastify from "fastify";
import { drsVerify } from "./drs-middleware.js";
const app = Fastify();
app.post(
"/tools/call",
{
preHandler: async (req, reply) => {
// Adapt the Express-shaped middleware to Fastify.
const next = () => {};
const expressRes = {
status: (n: number) => ({ json: (x: unknown) => reply.code(n).send(x) }),
};
await drsVerify(req as any, expressRes as any, next);
},
},
async (req) => {
return { ok: true, drs: (req as any).drs };
},
);
app.listen({ port: 3000 });
Performance notes
drs-verifyhandles DID resolution caching, nonce replay checking, and revocation lookups in one round-trip. Typical /verify latency against a local container is 5–15 ms (single-digit when caches are warm).- If the 5–15 ms hop matters, switch to the embedded Go middleware pattern — but that forces your tool server to be in Go.
Request-binding behavior
createDrsHttpMiddleware passes the actual parsed request body to /verify.
The verifier compares that body with the signed invocation.args using JCS. If
they differ, the middleware rejects the request before your handler runs.
Related
Integrate DRS with an A2A agent (Node / TypeScript)
Agent-to-Agent (A2A) differs from MCP in shape — both agents sit at equal standing and exchange tasks — but the DRS integration story is the same: the caller attaches a receipt bundle, the receiver verifies it before acting.
This guide covers the receiver side in Node. The caller side is the
same as React Native issuance and
MCP Node integration — you issue an invocation with
issueInvocation.
Architecture
Agent A (initiator) Agent B (receiver)
│
│ POST /a2a/task
│ X-DRS-Bundle: eyJ...
▼
┌──────────────────┐ ┌─────────────────────┐
│ Agent B (Node) │────────▶│ drs-verify sidecar │
│ 1. extract hdr │ POST │ ghcr.io/okeyamy/... │
│ 2. /verify │ /verify │ │
│ 3. if valid → │◀────────│ │
│ run task │ └─────────────────────┘
└──────────────────┘
Agent B is structurally the same as an MCP tool server — both verify an inbound bundle before executing. If you've already set up the MCP integration the code here is almost identical.
Install
pnpm add @drs/mcp-server
The actual cryptographic verification happens in the drs-verify container.
The Node package gives your receiver a secure enforcement point that rejects
invalid chains and body-binding mismatches before task execution.
Compose with Redis for shared replay protection
If Agent B is horizontally scaled across multiple instances, you need shared replay protection or an attacker can submit the same bundle to each replica in turn.
services:
agent-b:
build: .
deploy:
replicas: 3
environment:
DRS_VERIFY_URL: http://drs-verify:8080
drs-verify:
image: ghcr.io/okeyamy/drs-verify:latest
environment:
NONCE_STORE_BACKEND: redis
REDIS_URL: redis://redis:6379/0
deploy:
replicas: 2 # drs-verify itself can also scale — state is in Redis
redis:
image: redis:7-alpine
A2A middleware
// a2a-middleware.ts
import { createDrsHttpMiddleware } from "@drs/mcp-server";
const VERIFY_URL = process.env.DRS_VERIFY_URL ?? "http://localhost:8080";
const drs = createDrsHttpMiddleware({ verifyUrl: VERIFY_URL });
export async function drsA2A(req, res, next) {
const result = await drs(
{
headers: req.headers,
body: req.body,
},
(verifiedReq) => {
req.drs = verifiedReq.drs;
next();
},
);
if (!result.ok) return res.status(result.status).json({ drs_error: result.error });
}
Task handler
import express from "express";
import { drsA2A } from "./a2a-middleware.js";
const app = express();
app.use(express.json({ limit: "1mb" }));
app.post("/a2a/task", drsA2A, async (req, res) => {
// req.drs.root_principal is the original human/organisation
// req.drs.leaf_policy is the effective policy AFTER attenuation
const { task_type, payload } = req.body;
// A2A-specific: enforce that the task matches what's allowed by policy.
const allowedTools = req.drs.leaf_policy?.allowed_tools ?? [];
if (allowedTools.length > 0 && !allowedTools.includes(task_type)) {
return res.status(403).json({
error: "task_type not in allowed_tools",
allowed: allowedTools,
});
}
const result = await runA2ATask(task_type, payload, {
onBehalfOf: req.drs.root_principal,
});
res.json(result);
});
app.listen(3000);
JSON-RPC variant
Some A2A deployments use JSON-RPC instead of plain HTTP. The DRS
spec allows the bundle to live in _meta["X-DRS-Bundle"] instead of
a header.
app.post("/a2a/rpc", express.json(), async (req, res) => {
const bundleStr = req.body?._meta?.["X-DRS-Bundle"];
if (!bundleStr) return res.status(401).json({ error: "missing bundle" });
const bundle = JSON.parse(
Buffer.from(bundleStr, "base64url").toString("utf8"),
);
const r = await fetch(`${VERIFY_URL}/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(bundle),
});
const result = await r.json();
if (!result.valid) {
return res.json({
jsonrpc: "2.0",
id: req.body.id,
error: { code: -32001, message: "DRS verification failed", data: result.error },
});
}
// dispatch on req.body.method ...
});
Related
Integrate DRS with a non-MCP / non-A2A Node backend
Plenty of real-world services aren't MCP tool servers or A2A agents — they're ordinary APIs that want to enforce "this request came from an authorised delegation chain" before doing work. DRS still fits.
This guide covers three patterns for adding DRS to an existing Node backend:
- Express/Fastify middleware — add one
app.usecall. - Reverse proxy in front of an unchanged backend — zero application changes.
- Per-route opt-in — some routes enforce DRS, others don't.
Pattern 1: one-line middleware
Same shape as the MCP Node integration. Summary:
import { drsVerify } from "./drs-middleware.js";
app.use(drsVerify); // enforce on every route
app.use("/admin", drsVerify); // enforce on a subtree only
The middleware reads X-DRS-Bundle, POSTs to the sidecar verifier, and
either 401s/403s or attaches req.drs and calls next(). See the MCP
guide for the full implementation.
Pattern 2: reverse proxy (zero app changes)
Put drs-verify and a small proxy container in front of your existing
backend. Your app gets requests as if DRS were transparent, and a
header named X-DRS-Principal is added by the proxy so the app can
learn who authorised the call.
Cloudflare Workers, envoy, nginx with lua, or a tiny Node proxy all work. Here's the Node version:
// proxy.ts
import http from "node:http";
import { createProxyServer } from "http-proxy";
const VERIFY_URL = "http://drs-verify:8080";
const UPSTREAM = "http://my-existing-backend:5000";
const proxy = createProxyServer({ target: UPSTREAM, changeOrigin: true });
http.createServer(async (req, res) => {
const bundleHeader = req.headers["x-drs-bundle"];
if (!bundleHeader) {
res.writeHead(401, { "content-type": "application/json" });
return res.end(JSON.stringify({ error: "missing X-DRS-Bundle" }));
}
const bundle = JSON.parse(
Buffer.from(bundleHeader as string, "base64url").toString("utf8"),
);
const vr = await fetch(`${VERIFY_URL}/verify`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(bundle),
});
const result = await vr.json();
if (!result.valid) {
res.writeHead(403, { "content-type": "application/json" });
return res.end(JSON.stringify(result));
}
// Strip the bundle (contains sensitive signatures) and add a principal header.
delete req.headers["x-drs-bundle"];
req.headers["x-drs-principal"] = result.context.root_principal;
req.headers["x-drs-correlation-id"] = result.context.correlation_id ?? "";
proxy.web(req, res);
}).listen(8443);
Deploy this alongside drs-verify and your backend:
services:
edge:
build: ./proxy
ports: ["8443:8443"]
depends_on: [drs-verify, backend]
drs-verify:
image: ghcr.io/okeyamy/drs-verify:latest
backend:
image: your-app:latest # unchanged
The backend never learns DRS exists. It just sees X-DRS-Principal.
Pattern 3: per-route opt-in
For a mixed workload — some endpoints public, some require DRS, some require DRS + additional RBAC — make DRS enforcement explicit per route:
import { drsOptional, drsRequired } from "./drs-middleware.js";
app.get("/status", (req, res) => res.json({ ok: true })); // public
app.get("/report", drsOptional, (req, res) => {
// If bundle present, tailor the response to that principal.
// Otherwise return a generic report.
res.json(generateReport(req.drs?.root_principal));
});
app.post("/admin/delete", drsRequired, (req, res) => {
// DRS enforced. Additionally check operator role.
const principal = req.drs.root_principal;
if (!isOperator(principal)) return res.status(403).json({ error: "not operator" });
deleteThing(req.body.id);
res.status(204).end();
});
Policy enforcement at the app layer
drs-verify enforces attenuation (child policies can't escalate) but it
does not enforce runtime cost or per-call counting. Do that in your app:
app.post("/llm/complete", drsRequired, async (req, res) => {
const policy = req.drs.leaf_policy ?? {};
const estCost = estimateCost(req.body);
if (policy.max_cost_usd != null && estCost > policy.max_cost_usd) {
return res.status(403).json({
error: "exceeds policy.max_cost_usd",
max: policy.max_cost_usd,
estimated: estCost,
});
}
const result = await callLLM(req.body);
res.json(result);
});
Related
Install the SDK
Requirements
- Node.js 20+
- pnpm
Install
pnpm add @okeyamy/drs-sdk
Repository: https://github.com/OkeyAmy/DRS
TypeScript configuration
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022", "DOM"]
}
}
Verify the install
pnpm exec drs keygen
Expected output includes:
Ed25519 keypair generated.
DID : did:key:z6Mk...
Public key : <hex>
Private key : <hex>
What's in the package
The published package exports from the root entry only. Import from
@okeyamy/drs-sdk, not subpaths.
If you are wiring middleware guides from this docs site, use package names and paths exactly as shown in each page. Do not switch to legacy aliases.
import {
issueRootDelegation,
issueSubDelegation,
issueInvocation,
createInvocationBundle,
buildBundle,
serialiseBundle,
parseBundle,
computeChainHash,
checkPolicyAttenuation,
translatePolicy,
VerifyClient,
initWasm,
getWasmModule,
isWasmReady,
} from "@okeyamy/drs-sdk";
Browser / WASM notes
The SDK includes a WASM loader, but browser/WASM verification is still an explicit integration path:
VerifyClienttalks to a runningdrs-verifyHTTP serviceinitWasm()/getWasmModule()are available if you wire in a WASM build- there is no published standalone
@drs/wasmpackage in this repo today
Building the WASM package yourself
If you are developing locally and want to experiment with the WASM build:
cd drs-core
wasm-pack build --target web --features wasm
# Output: drs-core/pkg/
MCP Middleware Integration
Add DRS verification to an MCP server. The Go middleware verifies the
X-DRS-Bundle header before your business handler runs.
How it works
MCP client
│ POST /mcp/tools/call
│ X-DRS-Bundle: <base64url(JSON bundle)>
▼
drs-verify/pkg/middleware.MCPMiddleware
│ decode base64url
│ parse JSON bundle
│ run verify.Chain (blocks A–F)
▼ VALID
business handler
If verification fails:
- missing bundle:
401 - malformed base64url/JSON:
400 - invalid chain:
403
Go integration
If your MCP-facing server is in Go, wrap the route with
middleware.MCPMiddleware or middleware.OptionalMCPMiddleware.
package main
import (
"log"
"net/http"
"time"
"github.com/drs-protocol/drs-verify/pkg/middleware"
"github.com/drs-protocol/drs-verify/pkg/resolver"
"github.com/drs-protocol/drs-verify/pkg/verify"
)
func main() {
res, err := resolver.New(10_000, time.Hour)
if err != nil {
log.Fatal(err)
}
deps := verify.Deps{
Resolver: res,
}
mux := http.NewServeMux()
// 1) Define your normal business logic handler.
mcpBusinessHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 3) Read verification context after middleware has validated the bundle.
ctx := middleware.GetVerificationContext(r.Context())
if ctx == nil {
http.Error(w, "missing verification context", http.StatusForbidden)
return
}
// Example: make authorization/usage decisions with verified identity.
// ctx.RootPrincipal, ctx.ChainDepth, ctx.LeafPolicy
w.WriteHeader(http.StatusOK)
})
// 2) Wrap your business handler with MCP middleware.
mux.Handle("/mcp/", middleware.MCPMiddleware(deps,
mcpBusinessHandler,
))
log.Fatal(http.ListenAndServe(":8080", mux))
}
Use OptionalMCPMiddleware only when DRS is advisory and your business handler can
safely process requests without a bundle.
TypeScript / pure JSON-RPC integration
If your MCP traffic is pure JSON-RPC rather than HTTP-terminated, use the
TypeScript wrapper packages in packages/drs-mcp-client and
packages/drs-mcp-server.
- client side: injects the bundle into
params._meta["X-DRS-Bundle"] - server side: decodes the same base64url string and posts the decoded bundle to
/verify
This is the Shape 2 transport described in docs/drs-source-of-truth.md.
Testing your integration
# Valid bundle — expect exit code 0
DRS_VERIFY_URL=http://localhost:8080 pnpm exec drs verify bundle.json
# Missing bundle — expect 401
curl -X POST http://localhost:8080/mcp/tools/call \
-H "Content-Type: application/json" \
-d '{"tool":"web_search","query":"test"}'
# Malformed bundle — expect 400
curl -X POST http://localhost:8080/mcp/tools/call \
-H "X-DRS-Bundle: !!!not-base64url!!!" \
-H "Content-Type: application/json" \
-d '{"tool":"web_search","query":"test"}'
A2A Middleware Integration
DRS integrates with Agent-to-Agent (A2A) calls using the same bundle transport
as HTTP-terminated MCP: the full bundle travels in the X-DRS-Bundle header as
base64url-encoded JSON.
The middleware validates the bundle before your business handler executes.
Transport
POST /a2a/tasks/send HTTP/1.1
X-DRS-Bundle: <base64url(JSON bundle)>
Content-Type: application/json
The bundle must contain the full delegation chain from the original root to the current caller.
What happens on failure
- missing bundle:
401 - malformed base64url/JSON:
400 - invalid chain:
403
Go middleware
package main
import (
"log"
"net/http"
"time"
"github.com/drs-protocol/drs-verify/pkg/middleware"
"github.com/drs-protocol/drs-verify/pkg/resolver"
"github.com/drs-protocol/drs-verify/pkg/verify"
)
func main() {
res, err := resolver.New(10_000, time.Hour)
if err != nil {
log.Fatal(err)
}
deps := verify.Deps{
Resolver: res,
}
mux := http.NewServeMux()
// 1) Define your normal A2A business handler.
a2aBusinessHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 3) Read verification context after middleware validation.
ctx := middleware.GetVerificationContext(r.Context())
if ctx == nil {
http.Error(w, "missing verification context", http.StatusForbidden)
return
}
// Example: use ctx.RootPrincipal / ctx.ChainDepth / ctx.LeafPolicy.
w.WriteHeader(http.StatusOK)
})
// 2) Wrap your business handler with A2A middleware.
mux.Handle("/a2a/", middleware.A2AMiddleware(deps,
a2aBusinessHandler,
))
log.Fatal(http.ListenAndServe(":8080", mux))
}
Use OptionalA2AMiddleware only when DRS is advisory and your business handler
can safely process requests without a bundle.
Chain depth in multi-agent A2A topologies
In multi-agent A2A deployments, an orchestrator may dispatch to multiple worker agents. Each worker carries the full chain to itself:
Human → Orchestrator DR → Worker DR → Invocation
That worker bundle includes:
receipts[0]: root delegationreceipts[1]: orchestrator → worker sub-delegationinvocation: worker invocation receipt
The orchestrator issues the worker's sub-delegation before dispatch. The worker issues the invocation receipt when calling the next service.
Human Consent Records
When a human grants authority, the root DR must include a drs_consent record proving the user saw and approved the policy in human-readable form.
Why it matters
The policy field in a DR is machine-readable JSON. A user who clicks "Allow" on a form that shows them {"max_cost_usd":50} has not meaningfully consented — they have not understood what they approved.
The drs_consent.policy_hash is the SHA-256 of the human-readable text the user actually saw. Auditors can verify that the consent UI displayed legible information, not raw JSON.
Important: compute the hash from the exact rendered text shown to the user after localization and formatting.
Generating the human-readable text
Use the SDK's translatePolicy function:
import { translatePolicy } from '@okeyamy/drs-sdk';
const humanText = translatePolicy({
allowed_tools: ['web_search', 'write_file'],
max_cost_usd: 50.00,
pii_access: false,
write_access: false,
}, { locale: 'en-GB' });
// Output:
// Research Agent wants permission to:
// ✓ Search the web
// ✓ Save files to your workspace
// ✗ Cannot access personal data
// ✗ Cannot spend more than £50.00
Or via CLI:
echo '{"allowed_tools":["web_search"],"max_cost_usd":50}' | pnpm exec drs translate --locale en-GB
Computing the policy hash
import { computeChainHash } from '@okeyamy/drs-sdk';
// Hash the text the user saw — not the policy JSON
const policyHash = computeChainHash(humanText);
// "sha256:a1b2c3..."
Building the consent record
import { issueRootDelegation, computeChainHash, translatePolicy } from '@okeyamy/drs-sdk';
const policy = {
allowed_tools: ['web_search'],
max_cost_usd: 50.00,
pii_access: false,
};
// 1. Translate for the user
const humanText = translatePolicy(policy, { locale: 'en-GB' });
// 2. Show humanText in your consent UI
// await showConsentDialog(humanText); — user clicks Allow
// 3. Record their consent using the exact same text for policy_hash
const rootDR = await issueRootDelegation({
// ... other params ...
policy,
rootType: 'human',
consent: {
method: 'explicit-ui-click',
timestamp: new Date().toISOString(),
session_id: 'sess:' + crypto.randomUUID(),
policy_hash: computeChainHash(humanText),
locale: 'en-GB',
},
});
Consent methods
| Value | When to use |
|---|---|
explicit-ui-click | User clicked an "Allow" or "I agree" button |
explicit-ui-checkbox | User checked a checkbox next to each permission |
api-delegation | Programmatic delegation (organisation-rooted, no human interaction) |
operator-policy | Automated-system root — no human involved |
Machine-rooted delegations
If rootType is "automated-system" or "organisation", the consent field is optional. These root types are used by operators delegating to their own agents without per-session human approval.
const rootDR = await issueRootDelegation({
// ...
rootType: 'automated-system',
// consent: not required
});
Sub-Delegation
Sub-delegations allow an agent to pass a subset of its authority to another agent. The child policy must not escalate beyond the parent — this is the Principle of Least Authority (POLA).
Attenuation rules
| Parent policy field | Child constraint |
|---|---|
allowed_tools: [A, B, C] | Child allowed_tools must be ⊆ {A, B, C} |
max_cost_usd: 50 | Child max_cost_usd must be ≤ 50 |
pii_access: false | Child must have pii_access: false |
write_access: false | Child must have write_access: false |
max_calls: 100 | Child max_calls must be ≤ 100 |
exp: T | Child exp must be ≤ T |
nbf: T | Child nbf must be ≥ T |
Violation at issuance throws DrsError: POLICY_ESCALATION. Violation in a
received bundle fails Block D of verify_chain.
Practical model:
- SDK issuance checks protect you before signing invalid receipts.
- Verifier checks protect downstream services from invalid external bundles.
Example: narrowing authority
import { issueSubDelegation } from '@okeyamy/drs-sdk';
const parentPolicy = {
allowed_tools: ['web_search', 'write_file', 'read_file'],
max_cost_usd: 50.00,
pii_access: false,
write_access: true,
};
// Agent narrows authority before delegating
const subDR = await issueSubDelegation({
signingKey: agentPrivateKey,
issuerDid: 'did:key:z6MkAgent1...',
subjectDid: 'did:key:z6MkHuman...', // always the original human
audienceDid: 'did:key:z6MkAgent2...',
cmd: '/mcp/tools/call',
policy: {
allowed_tools: ['web_search'], // ⊆ parent's [web_search, write_file, read_file]
max_cost_usd: 5.00, // ≤ parent's 50
pii_access: false, // same (can't relax)
write_access: false, // tightened: parent allowed true
},
nbf: parentNbf, // ≥ parent's nbf
exp: parentNbf + 3600, // ≤ parent's exp
parentJwt: parentDR,
parentPolicy: parentPolicy,
parentNbf: parentNbf,
parentExp: parentExp,
});
What happens if you escalate?
// This throws POLICY_ESCALATION:
await issueSubDelegation({
policy: {
allowed_tools: ['web_search', 'write_file', 'execute_code'], // added execute_code
max_cost_usd: 100, // exceeded parent's 50
},
// ...
});
// DrsError: POLICY_ESCALATION — allowed_tools contains execute_code not in parent policy
The error fires before any signing. Invalid chains cannot be created.
The sub field never changes
The sub (subject) field represents the original resource owner — always the human at the root of the chain. It must remain identical through every sub-delegation:
rootDR.sub = "did:key:z6MkHuman..."
subDR.sub = "did:key:z6MkHuman..." ← same
inv.sub = "did:key:z6MkHuman..." ← same
Changing sub in a sub-delegation is a structural error caught by Block B.
Deploy drs-verify
drs-verify is a single static Go binary with no runtime dependencies. It compiles with CGO_ENABLED=0 and runs in a distroless container.
Docker (recommended for production)
docker pull ghcr.io/okeyamy/drs-verify:latest
docker run -d \
--name drs-verify \
-p 8080:8080 \
-e LISTEN_ADDR=:8080 \
-e DID_CACHE_SIZE=10000 \
-e DID_CACHE_TTL_SECS=3600 \
-e STATUS_LIST_BASE_URL=https://status.example.com \
-e STATUS_CACHE_TTL_SECS=300 \
-e DRS_ADMIN_TOKEN=your-secret-token \
ghcr.io/okeyamy/drs-verify:latest
For persistent receipt storage, mount a host or named volume and point STORE_DIR at it:
docker run -d \
--name drs-verify \
-p 8080:8080 \
-e LISTEN_ADDR=:8080 \
-e STORE_DIR=/var/lib/drs \
-v drs-verify-data:/var/lib/drs \
ghcr.io/okeyamy/drs-verify:latest
Build from source
cd drs-verify
CGO_ENABLED=0 GOOS=linux go build -o drs-verify ./cmd/server
./drs-verify
# drs-verify listening on :8080
Health checks
# Liveness
curl http://localhost:8080/healthz
# {"status":"ok"}
# Readiness (includes cache state)
curl http://localhost:8080/readyz
# {"status":"ready"}
Configure your load balancer or Kubernetes probe to use /readyz — it only returns ok when the server is fully initialised.
Kubernetes deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: drs-verify
labels:
app: drs-verify
spec:
replicas: 3
selector:
matchLabels:
app: drs-verify
template:
metadata:
labels:
app: drs-verify
spec:
containers:
- name: drs-verify
image: ghcr.io/okeyamy/drs-verify:latest
ports:
- containerPort: 8080
env:
- name: LISTEN_ADDR
value: ":8080"
- name: DID_CACHE_SIZE
value: "10000"
- name: DID_CACHE_TTL_SECS
value: "3600"
- name: STATUS_LIST_BASE_URL
value: "https://status.example.com"
- name: STATUS_CACHE_TTL_SECS
value: "300"
- name: DRS_ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: drs-secrets
key: admin-token
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /readyz
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
cpu: "500m"
Sidecar pattern
Running drs-verify as a sidecar that proxies requests to an upstream MCP server is a planned deployment mode. It is not implemented in the current release. For now, configure your MCP server to call POST /verify directly before accepting tool-call requests.
Operator Configuration
Machine-to-machine deployments (no live human in the delegation loop) use an OperatorConfig loaded at startup. This governs how the operator issues root delegations, manages key material, and handles out-of-policy requests.
Configuration file format
{
"drs_root_type": "automated-system",
"operator_did": "did:key:z6MkOperator...",
"operator_key_management": "env",
"standing_policy": {
"allowed_tools": ["web_search", "write_file", "read_file"],
"max_cost_usd": 100.00,
"pii_access": false,
"write_access": true
},
"renewal_rules": {
"auto_renew": true,
"session_ttl_hours": 8,
"max_renewal_count": 0
},
"escalation": {
"target_type": "organisation",
"supervisor_did": "did:key:z6MkSupervisor...",
"fallback": "deny"
},
"storage_tier": 1
}
Load in TypeScript:
import { parseOperatorConfig } from '@okeyamy/drs-sdk';
import { readFileSync } from 'fs';
const cfg = parseOperatorConfig(
JSON.parse(readFileSync('operator-config.json', 'utf-8'))
);
// Throws DrsError: INVALID_OPERATOR_CONFIG if any field is invalid
Key management options
operator_key_management | Where the key lives |
|---|---|
"env" | DRS_OPERATOR_KEY environment variable (base64url-encoded 32 bytes) |
"file" | Path in operator_key_path — raw 32-byte Ed25519 key file |
"aws-kms" | Configuration value reserved for an external AWS KMS signer integration |
"gcp-kms" | Configuration value reserved for an external GCP Cloud KMS signer integration |
Security: Never use
"file"or"env"in production with keys that have regulatory significance unless your deployment wraps signing in a separately reviewed secrets boundary.
Implementation note: these values are accepted by the configuration model only. This repository does not currently implement KMS-backed signing. Treat KMS/HSM signing as an external integration or production-hardening task, not as built-in runtime support.
Root type
drs_root_type | When to use |
|---|---|
"automated-system" | Fully automated operator — no human consent loop |
"organisation" | Represents an organisation that has a defined approval process |
Human-rooted delegations ("human") are not used in OperatorConfig — they require a live human consent interaction per session.
Renewal rules
| Field | Description |
|---|---|
auto_renew | If true, the agent runtime renews the session delegation before it expires |
session_ttl_hours | How long each session delegation is valid |
max_renewal_count | Maximum renewals per original session. 0 = unlimited |
Escalation behaviour
When an agent requests an action exceeding the standing_policy:
- Request is held (not rejected immediately)
- Notification sent to
supervisor_did - If supervisor approves: a new sub-DR is issued with expanded policy
- If supervisor does not respond within timeout:
fallbackapplies
fallback | Behaviour |
|---|---|
"deny" | Request is rejected — safe default |
"allow-degraded" | Request is allowed with a degraded policy — only use when availability is more critical than strict policy enforcement |
Security:
"allow-degraded"should never be the default in regulated deployments. Discuss with your security team before enabling it.
Storage tier field
storage_tier records the operator's intended receipt-retention posture. The
configuration schema accepts 0 through 5 so operator files can use the full
DRS vocabulary, but not every tier is implemented by the current verifier.
| Value | Current verifier behavior |
|---|---|
0 | In-memory store when STORE_DIR is unset |
1 | Local filesystem store when STORE_DIR is set |
2 | Roadmap only — no S3-compatible object-store backend in this release |
3 | Filesystem store plus RFC 3161 timestamp attempt when STORE_DIR and TSA_URL are set; WORM must be supplied by deployment infrastructure |
4 | Same backend as Tier 3, with timestamp verification/reporting requested by callers |
5 | Roadmap only — no Ethereum anchoring backend in this release |
See Storage Tiers for the canonical status table.
Storage Tiers
DRS uses a six-tier storage model. The canonical reference lives in
docs/storage-tiers.md; this page summarizes it and highlights what is actually
implemented today.
Tier reference
| Tier | Name | Backend | Env vars | Status |
|---|---|---|---|---|
| 0 | Session | In-memory | (none) | Implemented |
| 1 | Ephemeral | Local filesystem | STORE_DIR | Implemented |
| 2 | Durable | S3-compatible object store | roadmap | Not implemented |
| 3 | Compliant | Filesystem + RFC 3161 timestamping | STORE_DIR + TSA_URL | Partially implemented |
| 4 | Timestamped | Tier 3 deployment posture with timestamp retrieval/reporting | STORE_DIR + TSA_URL | Partially implemented |
| 5 | On-chain | Tier 3 + Ethereum anchor | roadmap | Not implemented |
What is actually implemented today
Tier 0: default when STORE_DIR is unset. Receipts are lost on restart.
Tier 1: receipts are written to the local filesystem and survive restart.
Tier 2: documented target only. There is no S3-compatible store in the current codebase.
Tier 3: when TSA_URL is set, drs-verify stores the receipt and attempts
RFC 3161 timestamping. This is best-effort:
- the receipt is still stored if the TSA is unavailable
- the timestamp is stored alongside the receipt when available
- WORM semantics are not enforced by the current filesystem backend
Tier 4: same backend as Tier 3. Today this is a reporting / operator posture rather than a separate storage engine.
Tier 5: Ethereum anchoring is a roadmap item, not a delivered feature.
Configuration
# Tier 0 — session / in-memory
LISTEN_ADDR=:8080 ./drs-verify
# Tier 1 — filesystem
LISTEN_ADDR=:8080 \
STORE_DIR=/data/drs \
./drs-verify
# Tier 3 / Tier 4 — filesystem + RFC 3161 timestamping
LISTEN_ADDR=:8080 \
STORE_DIR=/data/drs \
TSA_URL=https://freetsa.org/tsr \
./drs-verify
For the full canonical model, caveats, and tier semantics, see Canonical Storage Tiers.
Key Management
Key types and requirements
| Key type | Recommended storage | Rotation |
|---|---|---|
| Human root key | Hardware Security Module or device Secure Enclave | Not rotated — DID is derived from key |
| Operator root key | HSM/KMS or equivalent external signer for production | Annual with overlap period |
| Agent session key | Ephemeral — generated per session, never stored | Per-session |
Why agent keys must be ephemeral
Long-lived agent private keys are a liability: if the agent is compromised, all delegations signed by that key are at risk. Ephemeral keys limit the blast radius: a compromised key can only affect delegations from the current session, which expire when the session ends.
Generating keys
Development only (never in production):
pnpm exec drs keygen
# Private key: <base64url 32 bytes>
# DID: did:key:z6Mk...
Production operator keys:
Use a KMS/HSM-backed signer or another reviewed external signing service for
operator keys with regulatory significance. The current repository parses
operator_key_management values such as "aws-kms" and "gcp-kms" in
configuration, but it does not include built-in KMS signing code. Do not assume
that setting those values alone moves signing out of local process memory.
DID method choices
did:key (recommended): The DID is derived directly from the public key. No registry, no DNS, no trust anchor beyond the key itself. The verification key is self-contained in the DID string.
did:web: The DID is resolved by fetching a DID document from an HTTPS URL. Useful when you need to rotate keys without changing the DID (the DID document can be updated). Requires your domain's DNS and TLS to be secure — a compromised domain means a compromised DID.
Key rotation
For did:key DIDs, rotating the key means generating a new key and a new DID. The process:
- Generate new key and DID
- Update
operator_didin yourOperatorConfig - New root delegations are issued under the new DID
- Old delegations (signed with the previous key) remain valid until they expire
- After old delegations expire, the old key can be decommissioned
Protecting signing keys
- Never log private key material, even in debug builds
- Never store private keys in environment variables in production
- Do not treat
aws-kmsorgcp-kmsconfig values as built-in signing support until your deployment supplies and tests that signer integration - Never include private keys in Docker images
- Never commit keys to version control
Revocation
DRS supports two revocation mechanisms that work together:
- Remote Bitstring Status List — W3C standard; fetched from
STATUS_LIST_BASE_URLwith a configurable TTL cache (default 5 minutes). Revocations take effect within the cache window. - Local revocation store — in-memory; updated immediately via
POST /admin/revoke. Takes effect on the next request. Does not survive process restart.
Both are checked in Block F of verify_chain. A DR is revoked if its drs_status_list_index is marked in either source.
Revoking a delegation (immediate effect)
The local revocation store takes effect on the next verification request — no cache window:
curl -X POST http://localhost:8080/admin/revoke \
-H "Authorization: Bearer $DRS_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status_list_index": 42}'
# Response: {"revoked":true,"status_list_index":42}
DRS_ADMIN_TOKEN must be set as an environment variable. If not set, the endpoint responds 503:
{"error": "admin endpoint not configured — set DRS_ADMIN_TOKEN"}
Note: The local revocation store is in-memory. Revocations are lost on process restart. For durable revocation that survives restarts, update the W3C Bitstring Status List served at
STATUS_LIST_BASE_URL.
Revoking via the remote Bitstring Status List
To revoke durably (surviving restarts and across multiple drs-verify instances), update the W3C Bitstring Status List at the URL configured in STATUS_LIST_BASE_URL. drs-verify fetches and caches this list; the TTL is STATUS_CACHE_TTL_SECS (default 300 seconds / 5 minutes).
Reducing the cache window
For deployments where faster remote revocation propagation is needed:
STATUS_CACHE_TTL_SECS=30 ./drs-verify
Setting this too low increases load on the status list endpoint. 300 seconds is appropriate for most deployments. For emergency revocations, use POST /admin/revoke for immediate effect alongside updating the remote list.
How Block F works
for each DR in bundle:
if DR.drs_status_list_index is set:
if remote_status_list.is_revoked(DR.drs_status_list_index):
return RECEIPT_REVOKED
if local_revocation_store.is_revoked(DR.drs_status_list_index):
return RECEIPT_REVOKED
Status list cache concurrency
The status list cache uses sync.Once internally to prevent double-fetch race conditions under concurrent load. When the cache expires and multiple goroutines arrive simultaneously, only one HTTP request is made — all others wait and reuse the result.
Reconstruct a Delegation Chain
You can reconstruct and verify any delegation chain from stored receipts without operator cooperation. All you need are the JWT strings and the DIDs.
Step 1: Obtain the bundle
If the operator has already provided bundle.json, use that directly.
Or assemble a bundle manually from JWT strings:
{
"bundle_version": "4.0",
"invocation": "<invocation-receipt-jwt>",
"receipts": [
"<root-dr-jwt>",
"<sub-dr-jwt-1>"
]
}
Step 2: Verify the chain
pnpm exec drs verify evidence.json
This verifies the chain through drs-verify. The verifier reads the issuer DIDs
from the JWTs and resolves did:key locally from the DID bytes.
Step 3: Read the audit trail
pnpm exec drs audit evidence.json
Current drs audit output is intentionally compact. It prints bundle version,
receipt count, the main fields from each receipt, and the invocation's issuer,
command, and tool server.
Step 4: Verify the consent record
To confirm the user saw human-readable policy (not raw JSON):
The CLI does not read policies out of a bundle by receipt index. Instead,
extract the root receipt payload or save its policy object to a separate JSON
file, then run:
pnpm exec drs policy root-policy.json
Use your application-side consent records to relate the translated policy text
back to the stored policy_hash.
What you can prove
From the DRS chain alone, you can prove:
- Who authorised the action (the
issof the root DR, with their Ed25519 signature) - What they authorised (the
policyfield at every level) - When they authorised it (the
nbf,exp,iatfields) - What actually happened (the invocation receipt's
argsfield) - That consent was meaningful (the
drs_consent.policy_hashlinks to human-readable text) - That the chain is intact (all
prev_dr_hashvalues verify, all signatures valid)
You cannot prove these things from server logs alone.
Independent Verification
DRS chains can be verified by anyone, without contacting the operator, without a DRS account, and without any central authority.
What you need
- the DRS bundle (the JWT strings)
- the
drs verifyCLI from@okeyamy/drs-sdk - access to a
drs-verifyinstance you trust, including one you run yourself
What you do NOT need
- Access to the operator's systems or databases
- A DRS account or subscription
- Network access to the original issuer
- Any trusted third party to authenticate the evidence
Why this works
Each Delegation Receipt is signed with the issuer's Ed25519 private key. The issuer's public key is encoded directly in their did:key DID:
did:key:z6Mk{base58btc(multicodec_prefix + public_key_bytes)}
Anyone with the DID can derive the public key and verify the signature. No registry lookup, no HTTP request, no trust anchor beyond the public key.
Verification
DRS_VERIFY_URL=http://your-drs-verify-instance:8080 pnpm exec drs verify bundle.json
Or run your own verifier and point the CLI at it:
cd drs-verify && go run ./cmd/server &
pnpm exec drs verify bundle.json
Signature model
Each DRS JWT is an EdDSA JWT. The issuer DID encodes the Ed25519 public key:
did:key:z6Mk{base58btc(0xed01 + public_key_bytes)}
That lets any verifier derive the public key from the DID without contacting the original operator.
Export Evidence for EU AI Act
There is no dedicated drs audit export command in the current CLI. If you need
EU AI Act evidence today, assemble it from three artifacts:
- the bundle JSON itself
- the
drs verifyresult - the
drs auditoutput
Current workflow
pnpm exec drs verify bundle.json > verify.txt
pnpm exec drs audit bundle.json > audit.txt
cp bundle.json eu-ai-act-bundle.json
What you can include today
- the signed delegation chain (
bundle.json) - verifier output proving whether the chain is valid
- the audit trail showing issuer, audience, command, expiry, and tool server
- any external policy/consent records your application stored alongside the DRS flow
What is not automated yet
The repo does not currently ship:
drs audit export- EU AI Act-specific JSON schemas
- batch export by date range or subject
Those remain documentation and tooling work for a future release.
HIPAA Audit Evidence
For healthcare deployments handling PHI, DRS can provide tamper-evident proof of authorization and invocation activity. The current tooling is useful, but it is not yet a dedicated HIPAA export pipeline.
What DRS can show today
| HIPAA concern | Current DRS evidence |
|---|---|
| Record activity in PHI systems | signed invocation receipt |
| Verify authorization before access | signed delegation chain |
| Tamper evidence | Ed25519 signatures + prev_dr_hash chain |
| Independent verification | did:key-based signature checks |
Current evidence workflow
pnpm exec drs verify bundle.json > verify.txt
pnpm exec drs audit bundle.json > audit.txt
Archive these outputs together with the original bundle.json.
Storage caveat
The canonical storage model points regulated deployments toward Tier 3 / Tier 4
postures, but the current implementation does not enforce WORM semantics on the
filesystem backend. RFC 3161 timestamping is available when TSA_URL is set,
and TSA failures are best-effort.
See:
- Storage Tiers
docs/storage-tiers.md
What is not implemented
The current repo does not ship:
drs audit retrievedrs audit export --format hipaa- a HIPAA-specific export schema
If you need HIPAA packaging today, build it from the raw bundle plus verifier and audit outputs.
Development Setup
Prerequisites
Install all three language toolchains:
# Rust 1.77+
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup install stable
# Go 1.22+
# Download from https://go.dev/dl/ or via your package manager
# Node.js 20+ and pnpm
# Node: https://nodejs.org or nvm
npm install -g pnpm
# wasm-pack (for WASM builds)
cargo install wasm-pack
Clone and build
git clone https://github.com/OkeyAmy/DRS
cd DRS
# Rust core
cd drs-core
cargo build
cargo test
cd ..
# Go middleware
cd drs-verify
go build ./...
go test ./... -race
cd ..
# TypeScript SDK
cd drs-sdk
pnpm install
pnpm test
pnpm typecheck
cd ..
Optional: WASM build
cd drs-core
wasm-pack build --target web --features wasm
# Output: drs-core/pkg/
Run all tests
# Rust
cd drs-core && cargo test
# Go (with race detector and coverage)
cd drs-verify && go test ./... -race -coverprofile=coverage.out
go tool cover -html=coverage.out
# TypeScript
cd drs-sdk && pnpm test
cd drs-sdk && pnpm typecheck
Formatting and linting
# Rust
cd drs-core && cargo fmt && cargo clippy
# Go
cd drs-verify && gofmt -w . && go vet ./...
# TypeScript
cd drs-sdk && pnpm prettier --write .
CI enforces all formatters. cargo fmt --check, gofmt -l ., and pnpm prettier --check . must all pass.
Running drs-verify locally
cd drs-verify
go run ./cmd/server
# drs-verify listening on :8080
# In another terminal:
curl http://localhost:8080/healthz
# {"status":"ok"}
IDE setup
VS Code: Install the rust-analyzer, Go, and TypeScript extensions. The project includes workspace settings that configure formatters.
IntelliJ / GoLand: The Go module layout is standard — open drs-verify/ as a Go module.
Architecture Deep Dive
Read this before touching the crypto layer or the verification path.
Required reading
Before making changes to the core verification logic:
docs/Drs_language&algorithms.md— authoritative reference for language choices and corrected algorithmsdocs/drs-source-of-truth.md— current implementation contract- False Positives: What We Tried — the v1 and v2 failures
docs/Drs_architecture_v2.md is still useful, but as a historical prior-working-path document rather than the live implementation spec.
Module boundaries
Each module has exactly one responsibility. Do not write code that crosses these boundaries:
| Module | Responsibility | Does NOT do |
|---|---|---|
drs-core/src/crypto/ | Ed25519 sign/verify, SHA-256 | JWT parsing, policy evaluation |
drs-core/src/chain/ | Chain hash computation, verify_chain | Network I/O, caching |
drs-core/src/jcs/ | RFC 8785 canonicalisation | Serialisation to non-JSON formats |
drs-core/src/capability/ | Policy evaluation, attenuation check | Crypto operations |
drs-core/src/did/ | did:key decode to public key bytes | DID resolution with caching |
drs-verify/pkg/resolver/ | DID resolution + LRU cache | Chain verification |
drs-verify/pkg/verify/ | verify_chain (6 blocks) | DID resolution, HTTP I/O |
drs-verify/pkg/middleware/ | HTTP request/response handling | Verification logic |
drs-verify/pkg/policy/ | Policy field evaluation | Signing, serialisation |
drs-sdk/src/sdk/ | Issuance (sign + build JWTs) | Verification |
drs-sdk/src/verify/ | HTTP client to drs-verify | Verification logic itself |
drs-sdk/src/cli/ | CLI command dispatch | SDK logic |
Data flow
Issuance (TypeScript SDK):
Policy params → jcsSerialise(payload) → sign → JWT string
Verification (Go):
JWT string → parse header/payload → resolve_did → crypto/ed25519.Verify
→ check_policy_attenuation → revocation checks → VerificationResult
DID resolution (Go):
DID string → base58Decode → strip multicodec prefix [0xed, 0x01]
→ [32]byte public key (constant-time prefix check)
Adding a new algorithm
- Write it in Rust first (
drs-core/src/) - Add or extend shared conformance vectors when the protocol surface changes
- Port it to Go if the verifier needs the same rule on the hot path
- Keep TypeScript logic aligned with the conformance contract
Security-sensitive code checklist
Before merging any change to the crypto or verification path:
-
Comparisons on key material use constant-time equality (
subtle::ConstantTimeEq/crypto/subtle.ConstantTimeCompare) -
No
unwrap()in Rust library code — useResult<T, E>and propagate -
No
_on error returns in Go production paths — check and propagate - Signing keys are not logged, even in debug
- New error messages do not expose key material or sensitive internal state
Testing Standards
Every source file has a corresponding test file. Tests are not optional and are not written after the fact.
File structure
# Rust
drs-core/src/chain/verify.rs
drs-core/src/chain/verify_test.rs ← or #[cfg(test)] module inline
# Go
drs-verify/pkg/verify/chain.go
drs-verify/pkg/verify/chain_test.go ← same package, _test.go suffix
# TypeScript
drs-sdk/src/sdk/issue.ts
drs-sdk/src/sdk/issue.test.ts
What must be tested
For every module, write tests covering:
Happy path: Expected input produces expected output.
Boundary conditions:
- Empty input (empty chain, empty policy, empty args)
- Maximum chain depth (10 hops)
- Expired timestamps (
now > exp) - Not-yet-valid timestamps (
now < nbf)
Error paths: Every Err(...), error return, and DrsError throw has at least one test that triggers it.
Security properties — these are non-negotiable:
- Signature forgery must fail Block C
- Policy escalation must fail
checkPolicyAttenuationand Block D - Revoked chains must fail Block F
- Tampered DRs (modified payload) must fail Block C
- Chain splicing (wrong
prev_dr_hash) must fail Block B - Temporal violations (sub-DR
exp > parent exp) must fail Block E
RFC test vectors
jcs_canonicalise and compute_cid must pass all official RFC 8785 test vectors. These are non-negotiable:
#![allow(unused)] fn main() { #[test] fn test_jcs_rfc8785_vector_1() { // From RFC 8785 Appendix B let input = r#"{"b":1,"a":2}"#; let expected = r#"{"a":2,"b":1}"#; assert_eq!(jcs_canonicalise(input).unwrap(), expected); } }
Canonicalisation divergence between implementations breaks cross-implementation JWT verification. These tests are a compatibility guarantee, not just unit tests.
Running tests
# Rust — with coverage
cargo test
cargo tarpaulin --out Html # requires cargo-tarpaulin
# Go — with race detector
go test ./... -race -coverprofile=coverage.out
go tool cover -html=coverage.out
# TypeScript
pnpm test # vitest run
pnpm test -- --reporter=verbose
Test naming convention
Name tests to describe the property being tested, not the implementation:
# Good
TestSignatureForgeryShouldFail
TestPolicyEscalationRejectedAtIssuance
TestExpiredReceiptFailsTemporalBlock
# Bad
TestVerifyChain
TestIssueSubDelegation
TestBlock3
When a test fails, the name should tell you what property was violated.
False Positives: What We Tried
This is a research project. Three architectural approaches were designed and discarded before the current architecture. This page is preserved as a record of what failed and why — essential context for anyone contributing to the codebase.
"Those who cannot remember the past are condemned to repeat it." — George Santayana
v1: Invented from scratch
The hypothesis: We need a new delegation chain system for AI agents.
What we built: A custom delegation chain with Ed25519 signatures and a binary Merkle tree over the chain.
The errors:
Error 1 — Reinvented the wheel: UCAN (User Controlled Authorization Network) already defines cryptographic delegation chains. We built something equivalent but worse, without the benefit of the prior art and the community that had already reviewed it.
Error 2 — Wrong data structure: We applied a binary Merkle tree to a linear delegation chain. A linear chain is not a tree. The Merkle tree added complexity without benefit, and it introduced the same last-node duplication vulnerability as Bitcoin's transaction Merkle tree (CVE-2012-2459).
Error 3 — "Ed25519 is simply secure": This is not a security model. A security model names the threats, the mechanisms that counter them, and the residual risks. "Simply secure" means nothing to an auditor.
The lesson: Check whether the problem is already solved before designing a solution. Read the existing standards. Read the CVE history of similar approaches.
v1 documents: docs/DRS_architecture_v1.md
v2: UCAN Profile (wrong version)
The hypothesis: DRS should be a UCAN Profile — extend UCAN rather than invent something new. This was the correct insight.
What we built: A UCAN 0.x Profile with JWT-based delegation tokens.
The errors:
Error 1 — Wrong spec version: We built against UCAN 0.x (JWT-based, att.nb policy field) while the actual current specification was UCAN v1.0-rc.1 (CBOR/IPLD-based, cmd/pol policy). We discovered this when we tried to validate against real UCAN implementations.
Error 2 — TypeScript for verification: Verification is a high-frequency, latency-sensitive operation. V8 (Node.js) has GC pauses of 50–1500ms. The <5ms p99 latency requirement was mathematically impossible with TypeScript on the verification path. We measured 120ms median verification time under moderate load.
Error 3 — Unbounded DID resolver cache: The cache grew without bound under agent churn. With 10,000 active agents, the cache consumed >640MB. There was no eviction policy.
Error 4 — Status list race condition: Two concurrent requests arriving when the status list cache expired could both issue HTTP GET requests to the status list server. This is a double-fetch race condition. Under load, this caused 2–10× the expected traffic on the status list endpoint.
Error 5 — O(n·m) policy check: is_attenuated_subset() iterated over all n fields in the parent policy and all m fields in the child policy. At moderate load (1,000 req/sec with 5-level chains), this produced 25 million comparisons per second.
Error 6 — Wrong canonicalisation: We used JCS on JSON for JWT signing, but UCAN v1.0 uses CBOR encoding, not JSON. The JWT payloads were valid JSON but the wrong format for the specification we were implementing against.
The lesson: Read the specification you are implementing against before writing any code. Check the encoding format. Check the version number. Validate against a reference implementation early.
v2 documents: docs/Drs_architecture_v2.md
v3/v4: JWT-based DRS for OAuth/MCP ecosystems (current)
The pivot: From UCAN to JWT-based DRS aligned with the OAuth/MCP ecosystem.
Why the pivot: The ecosystem standardised on JWT-based infrastructure around OAuth and MCP. UCAN's production adoption is near-zero (Storacha is the only known production deployment). Building on UCAN would have meant building for a standard that the target ecosystem does not use.
What changed:
- UCAN envelopes/CBOR assumptions → DRS JWT receipts with RFC 8785 JCS canonicalisation
- TypeScript verification → Go verification server (goroutines, predictable GC)
- Unbounded cache →
golang-lru/v2with hard cap of 10,000 entries - Race condition →
sync.Onceon status list fetch - O(n·m) policy check → capability index with O(1) average lookup
- JSON/CBOR confusion → JWT throughout, JCS canonicalisation for signing
What stayed the same:
- Ed25519 signatures (correct from the start)
- The concept of per-step delegation receipts (correct from v2)
- The chain hash linking mechanism (correct from v2, minus the Merkle tree)
- The five-actor model (refined from v1)
The current architecture is not a revolution — it is the same core idea implemented correctly, on the right base layer, in the right languages.
Upstream Monitoring
DRS depends on external specifications and libraries that evolve. Upstream changes can break assumptions baked into our architecture without any change to the DRS codebase.
What to watch
| Source | What to watch | Why it matters |
|---|---|---|
ed25519-dalek (Rust) | RUSTSEC advisories, 2.x API changes | Core signing/verification library — RUSTSEC-2022-0093 is the reference for why we use 2.x |
serde-json-canonicalizer | RFC 8785 compliance updates, new test vectors | JCS divergence breaks cross-implementation JWT verification |
golang-lru/v2 | API changes, eviction policy updates | DID resolver cache — eviction semantics affect security properties |
W3C DID Core / did:key | Multicodec prefix changes, new key type support | The [0xed, 0x01] prefix check is hard-coded — any change breaks all DID resolution |
golang-jwt/jwt v5 | API changes, new algorithm support | JWT parsing in the Go verification server |
| MCP (Model Context Protocol) | Middleware adapter interface changes, new transport types | X-DRS-Bundle header integration — transport changes affect bundle delivery |
| A2A (Agent-to-Agent Protocol) | Interceptor interface changes | A2A middleware integration |
| IETF OAuth WG | RFC 8693 updates, new chain-splicing guidance | DRS is positioned as RFC 8693 mitigation #3 — spec changes affect our positioning |
| W3C Bitstring Status List | Spec changes to revocation format | Block F implementation |
When you notice a change
- Stop. Do not silently update the dependency or adapt the code.
- Open a GitHub issue with this format:
UPSTREAM CHANGE DETECTED
Source: <spec/crate/library name>
Version: <old version> → <new version>
What changed: <one sentence>
DRS impact: <which layer(s) and files are affected>
Recommended action: <what we need to decide>
Reference: <URL to release notes, advisory, or spec section>
- Wait for the maintainer to confirm before incorporating the change.
- Once confirmed, discuss architecture impact before writing any code.
The upstream drift lesson
v2 failed partly because it was built against UCAN 0.x while the actual specification was UCAN v1.0-rc.1. The difference between versions was not cosmetic — it was a complete change in encoding format (JSON → CBOR) and policy language (att.nb → cmd/pol).
Upstream drift caught during development is a discussion. Upstream drift caught in production is a vulnerability or a broken implementation.
Subscribing to security advisories
# Watch for Rust security advisories
cargo install cargo-audit
cargo audit # run periodically in CI
# Go vulnerability database
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
Both cargo audit and govulncheck should run in CI on every PR that touches dependencies.
JWT Fields Reference
All DRS JWTs use the header {"alg":"EdDSA","typ":"JWT"} with keys sorted by Unicode code point (RFC 8785 JCS). The signature covers base64url(header).base64url(payload).
Delegation Receipt payload
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
iss | DID string | Yes | Valid did:key or did:web | Issuer — the party granting delegation |
sub | DID string | Yes | Must match root DR's sub at every hop | Subject — the original resource owner; never changes through chain hops |
aud | DID string | Yes | Valid DID | Audience — the party receiving the delegation |
drs_v | string | Yes | Must be "4.0" | DRS specification version |
drs_type | string | Yes | Must be "delegation-receipt" | JWT type discriminator |
cmd | string | Yes | Non-empty | MCP command path, e.g. /mcp/tools/call |
policy | object | Yes | See Policy Schema | Capability constraints |
nbf | integer | Yes | Unix seconds; ≥ parent's nbf in sub-DRs | Not-before — when the delegation becomes valid |
exp | integer or null | Yes | Unix seconds; ≤ parent's exp in sub-DRs when both set | Expiry — null for standing delegations |
iat | integer | Yes | Unix seconds | Issued-at time |
jti | string | Yes | Format: dr: + UUID v4 | Unique identifier for revocation lookup |
prev_dr_hash | string or null | Yes | Format: sha256:{64 hex chars} or null | Hash of previous DR's JWT bytes; null at chain root |
drs_consent | object | When drs_root_type is "human" | See below | Human consent evidence |
drs_root_type | string | Yes on root DR | "human" | "organisation" | "automated-system" | Trust anchor type; absent on sub-DRs |
drs_regulatory | object | No | See below | Storage tier and retention requirements |
drs_status_list_index | integer | No | Non-negative | Position in Bitstring Status List; absent if revocation not used |
Invocation Receipt payload
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
iss | DID string | Yes | Must match last DR's aud | Issuer — the agent making the call |
sub | DID string | Yes | Must match root DR's sub | Subject — the original human |
drs_v | string | Yes | Must be "4.0" | DRS spec version |
drs_type | string | Yes | Must be "invocation-receipt" | Type discriminator |
cmd | string | Yes | Must match all DR cmd fields | MCP command path |
args | object | Yes | Evaluated against all DR policies | Actual invocation arguments |
dr_chain | string[] | Yes | Length = number of DRs; each sha256:{hex} | Ordered hashes of every DR in the chain |
tool_server | DID string | Yes | Valid DID | DID of the tool server |
iat | integer | Yes | Unix seconds | Issued-at time |
jti | string | Yes | Format: inv: + UUID v4 | Unique identifier |
ConsentRecord object
| Field | Type | Required | Description |
|---|---|---|---|
method | string | Yes | "explicit-ui-click" | "explicit-ui-checkbox" | "api-delegation" | "operator-policy" |
timestamp | ISO 8601 string | Yes | When the user consented |
session_id | string | Yes | Session identifier, prefixed sess: |
policy_hash | string | Yes | sha256:{hex} of the human-readable policy text the user saw |
locale | IETF language tag | Yes | Language of the consent UI (e.g. en-GB, fr-FR) |
RegulatoryMetadata object
| Field | Type | Description |
|---|---|---|
frameworks | string[] | Regulatory frameworks: "eu-ai-act-art13", "hipaa-164.312b", "sox", "finos-tier3" |
risk_level | string | "unacceptable" | "high" | "limited" | "minimal" |
retention_days | integer | Minimum retention in days (0 = forever) |
DRS Bundle
{
"bundle_version": "4.0",
"invocation": "<invocation-receipt-jwt>",
"receipts": ["<root-dr-jwt>", "<sub-dr-jwt-1>"]
}
Transmitted as X-DRS-Bundle: base64url({bundle_json}) HTTP header.
Policy Schema
The policy object defines capability constraints on a delegation. All fields are optional — an absent field means no constraint for that dimension.
Fields
| Field | Type | Default | Description |
|---|---|---|---|
allowed_tools | string[] | Unrestricted | Allowlist of MCP tool names. If absent, all tools are permitted. |
max_cost_usd | number | Unlimited | Maximum USD cost per invocation. Checked against args.estimated_cost_usd. |
pii_access | boolean | false | Whether access to personally identifiable information is permitted. |
write_access | boolean | false | Whether write operations are permitted. |
max_calls | integer | Unlimited | Maximum total invocations. Tracked by the agent runtime, not enforced by verify_chain. |
allowed_resources | string[] | Unrestricted | Allowlist of resource URIs. |
Policy evaluation (Block D)
Policies are evaluated conjunctively: every policy in the chain must pass. The invocation args are checked against every DR's policy from root to the immediate sub-DR.
For each DR's policy:
PASS if:
(policy.allowed_tools is absent) OR (args.tool ∈ policy.allowed_tools)
AND
(policy.max_cost_usd is absent) OR (args.estimated_cost_usd ≤ policy.max_cost_usd)
AND
(policy.pii_access is true) OR (args.pii_access is false or absent)
AND
(policy.write_access is true) OR (args.write_access is false or absent)
Attenuation rules
Sub-delegation policies must be strict subsets (attenuation) of the parent:
| Parent field | Child constraint |
|---|---|
allowed_tools: [A, B, C] | Child allowed_tools ⊆ {A, B, C} — cannot add new tools |
max_cost_usd: N | Child max_cost_usd ≤ N — cannot increase limit |
pii_access: false | Child must have pii_access: false — cannot re-enable |
write_access: false | Child must have write_access: false — cannot re-enable |
max_calls: N | Child max_calls ≤ N — cannot increase limit |
Example policies
Minimal (research agent, read-only):
{
"allowed_tools": ["web_search", "read_file"],
"max_cost_usd": 10.00,
"pii_access": false,
"write_access": false
}
Operator standing policy (automated system):
{
"allowed_tools": ["web_search", "write_file", "read_file", "execute_code"],
"max_cost_usd": 500.00,
"pii_access": false,
"write_access": true,
"max_calls": 10000
}
Single-tool sub-delegation (tight):
{
"allowed_tools": ["web_search"],
"max_cost_usd": 1.00,
"pii_access": false,
"write_access": false,
"max_calls": 10
}
Error Codes
SDK errors (TypeScript)
These errors are thrown by the SDK during issuance. They fire before any signing occurs — invalid chains cannot be created.
| Code | Thrown by | Description | Fix |
|---|---|---|---|
MISSING_CONSENT | issueRootDelegation | Human-rooted delegation issued without a consent field | Add consent to issueRootDelegation params |
POLICY_ESCALATION | issueSubDelegation | Child policy field exceeds parent constraint | Reduce the escalating field to be within parent bounds |
TEMPORAL_BOUNDS_VIOLATION | issueSubDelegation | nbf < parentNbf or exp > parentExp | Adjust temporal bounds to be nested within parent bounds |
INVALID_OPERATOR_CONFIG | validateOperatorConfig, parseOperatorConfig | Missing required field or invalid value | Check the field named in the error message |
Verification errors (Go — returned as HTTP 403 JSON)
These errors are returned by verify_chain when a bundle fails verification.
| Code | Block | Description |
|---|---|---|
BUNDLE_INCOMPLETE | A | Bundle has no receipts or is missing the invocation receipt |
ISSUER_AUDIENCE_GAP | B | receipts[i].aud ≠ receipts[i+1].iss |
CHAIN_HASH_MISMATCH | B | prev_dr_hash does not match SHA-256 of previous DR JWT bytes |
DR_CHAIN_MISMATCH | B | invocation.dr_chain[i] does not match SHA-256 of receipts[i] JWT bytes |
INVALID_JWT_HEADER | C | JWT header is not {"alg":"EdDSA","typ":"JWT"} |
DID_UNRESOLVABLE | C | Issuer DID could not be resolved to an Ed25519 public key |
SIGNATURE_INVALID | C | Ed25519 signature verification failed |
SIGNATURE_MALLEABILITY | C | Signature rejected: S ≥ L (strict mode violation) |
POLICY_VIOLATION | D | Invocation args exceed a policy constraint |
POLICY_ESCALATION | D | Sub-DR policy escalates beyond parent policy |
RECEIPT_NOT_YET_VALID | E | now < receipt.nbf |
RECEIPT_EXPIRED | E | now > receipt.exp |
TEMPORAL_BOUNDS_VIOLATION | E | Sub-DR temporal bounds not nested within parent bounds |
RECEIPT_REVOKED | F | Receipt found in Bitstring Status List |
HTTP 403 response body format
{
"valid": false,
"error": "CHAIN_HASH_MISMATCH",
"block": "B",
"message": "prev_dr_hash mismatch at chain index 1: expected sha256:abc123..., got sha256:def456..."
}
The message field always contains a full English sentence with diagnosis. The block field (A–F) tells you which verification stage failed.
CLI Commands
The drs CLI is included in @okeyamy/drs-sdk.
pnpm add -g @okeyamy/drs-sdk
drs --help
Or run it without a global install:
pnpm exec drs <command>
drs verify
Verify a bundle against a running drs-verify service.
drs verify [--include-timestamps] <bundle.json>
The CLI reads the verifier base URL from DRS_VERIFY_URL. If unset, it uses
http://localhost:8080.
Examples:
# Verify against local drs-verify
DRS_VERIFY_URL=http://localhost:8080 drs verify bundle.json
# Ask the server to retrieve and verify RFC 3161 timestamp tokens
drs verify --include-timestamps bundle.json
Exit codes: 0 = valid, 1 = invalid or command error.
drs audit
Print a human-readable audit trail for a bundle file.
drs audit <bundle.json>
Current output includes:
- bundle version
- receipt count
iss,aud,cmd,expfor each receiptiss,cmd,tool_serverfor the invocation
It does not currently export regulatory evidence packages or retrieve bundles by invocation ID.
drs policy
Translate a policy JSON file or a JSON document with a top-level policy field.
drs policy <receipt.json>
The command does not support --receipt. If you want the policy from a bundle,
extract one receipt payload first or save the policy to its own JSON file.
drs translate
Translate a policy JSON object to plain English.
drs translate <policy.json>
drs keygen
Generate a new Ed25519 keypair for development or testing.
drs keygen
Current output:
Ed25519 keypair generated.
DID : did:key:z6Mk...
Public key : <hex>
Private key : <hex>
Security: the private key is printed in plaintext hex. Do not commit it. Use a proper KMS or HSM for production keys.
API Endpoints
The drs-verify HTTP server exposes these endpoints.
POST /verify
Verify a DRS bundle. This is the primary endpoint.
Request:
POST /verify
Content-Type: application/json
{
"bundle_version": "4.0",
"invocation": "<invocation-receipt-jwt>",
"receipts": ["<root-dr-jwt>", "<sub-dr-jwt>"]
}
Body is capped at MAX_BODY_BYTES (default 1 MiB).
Response — valid chain (200):
{
"valid": true,
"context": {
"root_principal": "did:key:z6MkHuman...",
"chain_depth": 2,
"leaf_policy": {
"max_cost_usd": 0.10,
"allowed_tools": ["web_search"]
}
}
}
Response — invalid chain (200):
/verifyalways returns HTTP 200. Check thevalidfield to determine the outcome. HTTP 403 is only returned by the MCP/A2A middleware routes, not by/verifydirectly.
{
"valid": false,
"error": {
"code": "CHAIN_HASH_MISMATCH",
"message": "prev_dr_hash mismatch at chain index 1",
"suggestion": "Ensure receipts are in root-first order and were not modified after signing"
}
}
Response — malformed input (400):
{"error": "invalid character 'x' looking for beginning of value"}
Optional: request-body binding check
POST /verify accepts an optional body field in the JSON request — the
parsed request body the tool server received from its client. When present,
drs-verify canonicalises both the body and invocation.args using
RFC 8785 (JCS) and reports the relationship in result.binding:
binding value | Meaning |
|---|---|
"match" | Body canonically equals invocation.args. The body is bound to what was signed. |
"mismatch" | Chain verified but body diverges from args. Likely tampering between signing and execution. |
"invalid_body" | Body was included but could not be parsed as JSON. |
| (field absent) | Body was not sent; no binding check ran. |
result.valid stays cryptographic truth (chain + policy + signature).
binding is a distinct signal; the tool server decides what to do with
"mismatch". A common pattern:
if (!result.valid) return reject(result.error);
if (result.binding === "mismatch") return reject({ code: "BINDING_MISMATCH" });
// proceed to execute the tool against the verified body
Example request:
POST /verify
{
"bundle_version": "4.0",
"invocation": "<invocation-receipt-jwt>",
"receipts": ["<root-dr-jwt>", "<sub-dr-jwt>"],
"body": { "tool": "approve_payment", "transaction_id": "T1" }
}
Example response with binding match:
{
"valid": true,
"context": { ... },
"binding": "match"
}
What drs-verify does NOT do
drs-verify is a verification service only. It does not proxy, transform,
or execute MCP/A2A traffic. Tool servers own their own endpoints and call
POST /verify on a local drs-verify instance for each request. See
examples/drs-expense-agent/src/tool-server.ts for the canonical
tool-server pattern, or import github.com/drs-protocol/drs-verify/pkg/middleware
for in-process Go integrations.
GET /healthz
Liveness check.
GET /healthz
Response (200):
{"status": "ok"}
Returns 503 only if the server cannot handle requests (e.g., during shutdown).
GET /readyz
Readiness check. Returns 200 when the server is fully initialised and ready to handle verification requests; 503 when not ready.
GET /readyz
Response (200 — ready):
{"status": "ready"}
Response (503 — not ready):
{"status": "not_ready", "reason": "status_list_not_fetched"}
Use /readyz for Kubernetes readiness probes. Use /healthz for liveness probes.
Note: If
STATUS_LIST_BASE_URLis not configured, the status list cache is skipped and/readyzalways returns 200 immediately.
GET /metrics
Prometheus exposition endpoint.
GET /metrics
The endpoint is unauthenticated and exempt from the built-in rate limiter so
monitoring systems can scrape it reliably. In production, expose /metrics
only to your monitoring network through your reverse proxy, firewall, service
mesh, or Kubernetes NetworkPolicy.
POST /admin/revoke
Mark a delegation receipt as locally revoked by its status list index. Takes effect immediately — does not wait for the remote Bitstring Status List to refresh.
DRS_ADMIN_TOKEN must be set as an environment variable. If not set, the endpoint responds 503.
POST /admin/revoke
Authorization: Bearer <DRS_ADMIN_TOKEN>
Content-Type: application/json
{"status_list_index": 42}
Body is capped at 1 KiB.
Response (200):
{"revoked": true, "status_list_index": 42}
Response — admin not configured (503):
{"error": "admin endpoint not configured — set DRS_ADMIN_TOKEN"}
Response — wrong or missing token (401):
{"error": "unauthorized"}
By default, local revocation is in-memory and affects only the current
drs-verify process. Set REVOCATION_STORE_PATH to enable the file-backed
local revocation store; successful /admin/revoke calls are appended and
fsynced so they survive process restart on that instance.
For multi-instance or cross-region durability, update the W3C Bitstring Status
List at your STATUS_LIST_BASE_URL endpoint. The file-backed local store is not
a distributed revocation backend.
Configuration Reference
All configuration is via environment variables. No hard-coded URLs, ports, or keys in any DRS component.
drs-verify environment variables
| Variable | Default | Description |
|---|---|---|
LISTEN_ADDR | :8080 | HTTP listen address (e.g. 0.0.0.0:8080, :443) |
DID_CACHE_SIZE | 10000 | LRU DID resolver cache maximum entries. Hard cap — entries are evicted when full (~640 KB at 10 000 entries). |
DID_CACHE_TTL_SECS | 3600 | DID resolver cache entry TTL in seconds. |
STATUS_LIST_BASE_URL | — | W3C Bitstring Status List endpoint base URL. Required for remote revocation (Block F). |
STATUS_CACHE_TTL_SECS | 300 | Bitstring Status List cache TTL in seconds. Revocations take effect within this window. |
MAX_BODY_BYTES | 1048576 | Maximum request body size in bytes for /verify (default 1 MiB). |
LOG_LEVEL | info | Log verbosity: debug, info, warn, or error. |
LOG_FORMAT | text | Log format: text or json. Use json for log aggregation. |
SERVER_IDENTITY | — | This verifier's DID or server identifier. When set, /verify rejects invocations whose tool_server does not match. Empty disables destination binding. |
DRS_ADMIN_TOKEN | — | Bearer token required for POST /admin/revoke. If not set, the endpoint responds 503. No default — set explicitly to enable. |
REVOCATION_STORE_PATH | — | Optional file path for durable local /admin/revoke state. Empty uses in-memory local revocation only. |
NONCE_STORE_BACKEND | memory | Replay-protection backend: memory for single-process deployments, redis for restart-safe and multi-replica deployments. |
REDIS_URL | — | Required when NONCE_STORE_BACKEND=redis. Supports redis:// and rediss:// URLs. |
TRUST_PROXY | false | When true, rate limiting uses the rightmost X-Forwarded-For entry. Enable only behind a trusted reverse proxy. |
RATE_LIMIT_PER_IP | 100 | Sustained requests per second per client IP. |
RATE_LIMIT_GLOBAL | 1000 | Sustained requests per second across all clients. |
STORE_DIR | — | Base directory for the filesystem store. Empty = Tier 0 in-memory (dev/test). Set for Tier 1 or Tier 3. |
TSA_URL | — | RFC 3161 Timestamp Authority endpoint. Enables Tier 3 trusted timestamping only when STORE_DIR is also set — if STORE_DIR is empty, TSA_URL is silently ignored and the server falls back to Tier 0 (in-memory). Providers: https://freetsa.org/tsr (free), https://timestamp.digicert.com. |
TSA_ROOT_CERT_PEM | — | Optional PEM root pool for RFC 3161 timestamp verification. Empty uses system roots. |
METRICS_ADDR | — | Listen address for the separate Prometheus /metrics endpoint (e.g. :9090 for dev, 127.0.0.1:9090 for production). Empty disables the metrics endpoint. Served on its own listener so it can be firewalled independently of the main API port. |
drs-sdk CLI environment variables
| Variable | Default | Description |
|---|---|---|
DRS_VERIFY_URL | — | drs-verify base URL used by drs verify and VerifyClient. |
Example configurations
# Tier 0 — in-memory (development default)
LISTEN_ADDR=:8080 ./drs-verify
# Tier 1 — filesystem store
LISTEN_ADDR=:8080 \
STORE_DIR=/data/drs \
STATUS_LIST_BASE_URL=https://status.example.com \
./drs-verify
# Tier 3 — filesystem + RFC 3161 timestamp anchor (regulated deployments)
LISTEN_ADDR=:8080 \
STORE_DIR=/data/drs \
TSA_URL=https://freetsa.org/tsr \
DRS_ADMIN_TOKEN=your-secret-token \
STATUS_LIST_BASE_URL=https://status.example.com \
./drs-verify
Docker Compose example
version: '3.8'
services:
drs-verify:
image: ghcr.io/okeyamy/drs-verify:latest
ports:
- "8080:8080"
environment:
LISTEN_ADDR: ":8080"
DID_CACHE_SIZE: "10000"
DID_CACHE_TTL_SECS: "3600"
STATUS_LIST_BASE_URL: "https://status.example.com"
STATUS_CACHE_TTL_SECS: "300"
DRS_ADMIN_TOKEN: "${DRS_ADMIN_TOKEN}"
REVOCATION_STORE_PATH: "/data/revoked.log"
NONCE_STORE_BACKEND: "memory"
SERVER_IDENTITY: "did:key:z6MkToolServer..."
STORE_DIR: "/data"
TSA_URL: "https://freetsa.org/tsr"
volumes:
- drs-data:/data
volumes:
drs-data:
The published image is distroless, so container-internal shell healthcheck commands such as wget or curl are not available. Probe /healthz and /readyz from Docker, Kubernetes, or your external load balancer instead.
DRS vs Alternatives
DRS is a narrow standard solving a specific problem. Understanding what it is and is not helps you decide where it belongs in your stack.
Comparison table
| System | Core purpose | Independently verifiable? | Per-step receipts? | OAuth ecosystem? | Production adoption |
|---|---|---|---|---|---|
| DRS | Delegation chain receipts | ✓ Yes | ✓ Yes | ✓ Yes | Research / early |
| OAuth 2.1 | User → service delegation | — | — (no receipts) | ✓ Yes | Universal |
| RFC 8693 | Token exchange between agents | — | — (bearer tokens) | ✓ Yes | Growing |
| UCAN | Capability-based delegation | ✓ Yes | ✓ Yes | ✗ CBOR/IPLD | ~1 production user |
| OpenTelemetry | Distributed tracing | ✗ Operator-controlled | — (spans, not receipts) | Agnostic | Universal |
| Langfuse / Arize | LLM observability | ✗ Operator-controlled | — (logs/evals) | Agnostic | Growing |
| Agentic JWT | JWT profile for agent identity | Partial | — (identity, not chains) | ✓ Yes | Research |
Why not UCAN?
UCAN is technically correct. The reason DRS uses a JWT-based design aligned with the OAuth ecosystem instead is ecosystem adoption:
- AT Protocol chose JWT + OAuth 2.1
- MCP (Model Context Protocol) chose JWT + OAuth 2.1
- The LLM agent ecosystem is converging on OAuth-based token exchange
UCAN's production deployment is approximately one system (Storacha/web3.storage). Building DRS on UCAN would have meant building for a standard that the target ecosystem does not use. You cannot get enterprises to adopt an accountability standard that requires them to also adopt CBOR/IPLD and a new DID infrastructure.
DRS solves the same cryptographic problem as UCAN (independently verifiable delegation chains) but uses JWT receipts, JCS canonicalization, and DRS-specific fields in a shape that fits the surrounding OAuth/MCP ecosystem.
Why not OpenTelemetry?
OpenTelemetry traces are observability data. They tell you what happened from the operator's perspective, stored in operator-controlled infrastructure (Jaeger, Grafana, Datadog).
DRS receipts are authorisation proofs. They tell you what was permitted, signed by the authorising party, verifiable by anyone with the public key.
The critical difference: an attacker who compromises the operator can delete or falsify OTel traces. They cannot forge DRS receipts without the private key. For regulatory compliance ("prove what happened"), cryptographic proofs are required — logs are not sufficient.
Use OpenTelemetry for debugging and monitoring. Use DRS for compliance and audit.
Why not server logs?
Server logs are:
- Operator-controlled — the operator can modify or delete them
- Not cryptographically bound to the authorising party
- Not independently verifiable — an auditor must trust the operator's infrastructure
DRS receipts are:
- Signed by the authorising party — the operator cannot forge them
- Verifiable by anyone — no trust in the operator's infrastructure required
- Tamper-evident — modification breaks the Ed25519 signature
For compliance purposes, "we have logs showing what happened" is weaker than "we have cryptographic proofs signed by the authorising parties." The EU AI Act, HIPAA, and AIUC-1 requirements are moving toward the latter.
When to use each
| Your goal | Use |
|---|---|
| Track agent performance, latency, costs | OpenTelemetry + Langfuse/Arize |
| Authenticate users to your service | OAuth 2.1 |
| Exchange tokens between agents | RFC 8693 |
| Prove what an agent was authorised to do | DRS |
| Meet EU AI Act Article 12/13 requirements | DRS |
| Meet HIPAA §164.312(b) audit controls | DRS |
| Get AIUC-1 certification | DRS |
Roadmap
DRS is still a research project, but several items previously listed as future work are now implemented.
Phase 1 — Core protocol
Status: Mostly complete
- ✓
drs-core: Rust crypto primitives, JCS canonicalisation, chain hash, policy primitives - ✓
drs-verify: Go verification server, MCP/A2A middleware, DID resolver cache, revocation, local revoke endpoint - ✓
drs-sdk: TypeScript issuance SDK and CLI (verify,audit,policy,translate,keygen) - ✓ Shared conformance suite across Rust, Go, and TypeScript
- ✓ RFC 3161 timestamping support
- ✓
did:webresolver SSRF hardening and circuit breaker
Phase 2 — Production hardening
- secure-by-default Node HTTP enforcement middleware (
@drs/mcp-server) - HSM / KMS integration in the verifier
- durable object-store backend (Tier 2 roadmap)
- stronger retention / immutability story for regulated deployments
- external security review
- repeatable performance benchmarks
- workspace-level release and CI orchestration
Phase 3 — Ecosystem integration
- richer MCP/A2A integration guidance and examples built around reusable middleware
- browser-focused verification flows using the WASM build
- stronger TypeScript packages for pure JSON-RPC MCP transport
- Ethereum anchoring as explicit Tier 5 opt-in
- richer policy language extensions
Phase 4 — Standards track
- standards-track documentation and draft work
- external governance / interoperability alignment
- stronger regulatory reference material once implementation catches up
Non-goals
- behavioral safety or prompt injection prevention
- model determinism
- post-compromise key recovery
- DID lifecycle management outside DRS itself
Glossary
Attenuation — The constraint that a sub-delegation's policy must be a strict subset of its parent's policy. A sub-agent can only be granted less authority than the agent that delegated to it. See Principle of Least Authority (POLA).
Bundle — The unit of transport in DRS. A JSON object containing the invocation receipt and all delegation receipts in the chain, transmitted as X-DRS-Bundle: base64url({bundle_json}).
Chain splicing — An attack where an adversary substitutes an unrelated token into a delegation chain to exceed the scope they were actually granted. CVE-2025-55241 (Azure AD, 2025) is a documented instance. DRS mitigates this with prev_dr_hash.
Delegation Receipt (DR) — A signed JWT issued by each delegator recording one hop in the delegation chain. Contains issuer DID, audience DID, command, policy constraints, temporal bounds, and a hash linking to the previous DR.
DID (Decentralised Identifier) — A URI that identifies an actor without a central registry. Format: did:method:identifier. DRS uses did:key and did:web.
did:key — A DID where the identifier encodes the public key directly: did:key:z{base58btc(multicodec_prefix + pubkey_bytes)}. No registry, no DNS. Preferred for DRS because it is self-contained and requires no network resolution.
did:web — A DID whose identifier is a domain name. Resolved by fetching a DID document from https://domain/.well-known/did.json. Requires DNS and TLS security.
DR Store — The storage backend for delegation receipts. One of five tiers from in-memory (tier 0) to on-chain (tier 4).
EdDSA / Ed25519 — The signature algorithm used in all DRS JWTs. Deterministic (no random nonce), constant-time, and immune to fault attacks. DRS uses ed25519-dalek 2.x (Rust) and golang.org/x/crypto (Go).
Invocation Receipt — A signed JWT recording an actual tool call. Contains the command, arguments, the ordered array of DR hashes (dr_chain), and the tool server's DID.
JCS (JSON Canonicalization Scheme) — RFC 8785. Defines a canonical serialisation of JSON where object keys are sorted recursively by Unicode code point with no whitespace. Used by DRS to ensure identical JWT bytes for logically equivalent objects across all implementations.
JTI (JWT ID) — Unique identifier for a JWT. DRS format: dr:uuid-v4 for delegation receipts, inv:uuid-v4 for invocation receipts.
MCP (Model Context Protocol) — Anthropic's protocol for connecting language models to external tools. DRS bundles are transmitted as X-DRS-Bundle headers on MCP requests.
Multicodec — A self-describing binary encoding prefix used in did:key. For Ed25519, the prefix is [0xed, 0x01]. DRS uses constant-time comparison to check this prefix.
POLA (Principle of Least Authority) — Each delegation grants only the authority needed for the specific task. Sub-delegations must be strictly less permissive than their parent. POLA is enforced both at issuance (SDK) and at verification (Block D).
prev_dr_hash — The field in each sub-DR that links it to its parent: "sha256:{lowercase hex of SHA-256 of parent DR JWT bytes}". Null at the chain root. Creates a tamper-evident chain — any modification to any DR changes its hash and breaks subsequent links.
RFC 8693 — IETF Token Exchange. Defines how one OAuth bearer token can be exchanged for another representing a different principal acting on behalf of the original user. DRS is designed to address the same chain-splicing problem space, but this repository does not itself implement RFC 8693 token-exchange flows.
sub (Subject) — The JWT claim identifying the original resource owner — always the human at the root of the chain. The sub field must remain identical through every delegation hop. It is never the agent.
Verify_chain — The Go function that runs all six verification blocks (A–F) on a DRS bundle. Fail-closed: any error immediately rejects the request without continuing.