DEV Community

willamhou
willamhou

Posted on

How I Built Cryptographic Signing for Every AI Agent Tool Call

How I Built Cryptographic Signing for Every AI Agent Tool Call

Your AI agent just mass-deleted a production database. Can you prove exactly what it did? When? Who authorized it?

You can't. None of the major agent frameworks produce cryptographic evidence of what happened.

I built Signet to fix this. It gives every AI agent an Ed25519 identity and signs every tool call with a tamper-evident receipt. Open source, 3 lines of code to integrate.

This post covers the design decisions, the crypto choices, and why I built an SDK instead of a proxy.

The Problem

MCP (Model Context Protocol) is becoming the standard for agent-tool communication. Claude, Cursor, Windsurf, and dozens of other tools use it. But MCP has no signing, no audit log, and no way to prove which agent did what.

Real incidents that motivated this:

  • Amazon Kiro deleted a production environment
  • Replit agent dropped a live database
  • Supabase MCP leaked tokens via prompt injection

In every case: zero audit trail. The agent did something, nobody could prove exactly what.

Design Decisions

SDK, not proxy

The existing tools in this space (Aegis, estoppl) use a proxy/gateway model. You deploy a separate process that sits between your agent and the MCP server, intercepting all traffic.

I chose the opposite: a client-side SDK. Three reasons:

  1. Zero infrastructure. npm install or pip install, not Docker.
  2. stdio-native. Most MCP connections use stdio pipes. Proxying stdio requires process orchestration that adds failure modes.
  3. Framework-agnostic. Works with any MCP client, any transport, any language.

The tradeoff: a proxy can enforce policies (block unauthorized calls). Signet can't — it's a camera, not a bouncer. Attestation, not prevention. I think both are needed, and they're complementary.

Ed25519, not ECDSA or RSA

Ed25519 was the obvious choice:

  • Fast. ~70,000 signatures/sec on a laptop. Agent tool calls are maybe 10/sec. No bottleneck.
  • Small. 64-byte signatures, 32-byte keys. Fits in JSON metadata without bloat.
  • Deterministic. Same key + same message = same signature. No nonce generation needed at signing time (the nonce is in the key schedule).
  • Battle-tested. SSH, Signal, age, WireGuard all use it.

I use ed25519-dalek in Rust. The same core compiles to WASM for Node.js and to native for Python via PyO3. One implementation, three languages, zero divergence risk.

RFC 8785 (JCS) for canonical JSON

The signature covers the entire receipt body: action, signer, timestamp, nonce. But JSON serialization is non-deterministic — {"a":1,"b":2} and {"b":2,"a":1} are semantically identical but produce different bytes.

I use RFC 8785 (JSON Canonicalization Scheme) to solve this. JCS defines a deterministic JSON serialization: sorted keys, no whitespace, specific number formatting. Sign the JCS output, verify against the JCS output. Deterministic.

Why JCS over alternatives like bencode or CBOR? Because the receipts are JSON, the MCP protocol is JSON, and I didn't want to introduce a second serialization format just for signing.

Hash-chained audit log

Every receipt is appended to a local JSONL audit log. Each entry includes the SHA-256 hash of the previous entry:

record_1: { receipt: ..., prev_hash: "sha256:0000...", record_hash: "sha256:abc1..." }
record_2: { receipt: ..., prev_hash: "sha256:abc1...", record_hash: "sha256:def2..." }
record_3: { receipt: ..., prev_hash: "sha256:def2...", record_hash: "sha256:ghi3..." }
Enter fullscreen mode Exit fullscreen mode

Delete or modify any record and the chain breaks. signet verify --chain detects it.

This is tamper-evident, not tamper-proof. Someone with disk access can delete the entire log. The hash chain catches selective editing (changing one record while keeping others). Off-host anchoring (anchoring chain hashes to an external service) is on the roadmap for true tamper-proof guarantees.

Encrypted key storage

Agent keys are encrypted at rest with Argon2id + XChaCha20-Poly1305:

  • Argon2id for key derivation (OWASP recommended, memory-hard, resists GPU attacks)
  • XChaCha20-Poly1305 for encryption (24-byte nonce = safe random nonce generation, AEAD for authenticated encryption)
  • AAD (Additional Authenticated Data) binds the ciphertext to the key's metadata, preventing key-file swaps

Unencrypted keys are supported for CI/automation (--unencrypted flag). Keys stored at ~/.signet/keys/ with 0600 permissions.

What a Receipt Looks Like

{
  "v": 1,
  "id": "rec_e7039e7e7714e84f...",
  "action": {
    "tool": "github_create_issue",
    "params": {"title": "fix bug", "body": "details"},
    "params_hash": "sha256:b878192252cb...",
    "target": "mcp://github.local",
    "transport": "stdio"
  },
  "signer": {
    "pubkey": "ed25519:0CRkURt/tc6r...",
    "name": "demo-bot",
    "owner": "willamhou"
  },
  "ts": "2026-03-29T23:24:03.309Z",
  "nonce": "rnd_dcd4e135799393...",
  "sig": "ed25519:6KUohbnSmehP..."
}
Enter fullscreen mode Exit fullscreen mode

The signature covers v + action + signer + ts + nonce via JCS. Tamper with any field and verification fails.

The params_hash is always present. By default, raw params are also stored. For sensitive data, --hash-only mode stores only the hash — you can prove what shape the params had without revealing their content.

Integration: 3 Lines

TypeScript (MCP)

import { SigningTransport } from "@signet-auth/mcp";

const inner = new StdioClientTransport({ command: "my-mcp-server" });
const transport = new SigningTransport(inner, secretKey, "my-agent");
Enter fullscreen mode Exit fullscreen mode

Every tools/call request gets a signed receipt injected into params._meta._signet. MCP servers don't need to change — they ignore unknown fields per the spec.

Python (LangChain / CrewAI / AutoGen)

from signet_auth import SigningAgent

agent = SigningAgent.create("my-agent", owner="willamhou")
receipt = agent.sign("github_create_issue", params={"title": "fix bug"})
Enter fullscreen mode Exit fullscreen mode

The Python binding is Rust compiled via PyO3 — same crypto, same behavior, native speed.

CLI

signet identity generate --name my-agent
signet sign --key my-agent --tool "github_create_issue" --params '{"title":"fix bug"}'
signet audit --since 24h
signet verify --chain
Enter fullscreen mode Exit fullscreen mode

Architecture

signet/
├── crates/signet-core/       Rust core (one implementation)
├── bindings/
│   ├── signet-ts/            → WASM for Node.js
│   └── signet-py/            → PyO3 for Python
├── packages/
│   ├── @signet-auth/core     TypeScript wrapper
│   └── @signet-auth/mcp      MCP middleware
└── signet-cli/               CLI binary
Enter fullscreen mode Exit fullscreen mode

The key architectural decision: one Rust implementation, compiled to multiple targets. The WASM binding and Python binding call the same signet-core code. There is no separate TypeScript or Python reimplementation of the signing logic. This eliminates the "two implementations diverge" class of bugs entirely.

141 tests across Rust (64), Python (66), and TypeScript (11).

What Signet Does NOT Do (Yet)

It proves the agent requested an action, not that the server executed it. The receipt says "agent X signed intent to call tool Y with params Z at time T." Whether the server actually did it is a separate question. Server-side verification middleware is on the roadmap.

It doesn't prevent bad actions. Signet is a camera, not a bouncer. It complements prevention tools like policy engines and firewalls. You want both: stop the bad thing AND have evidence of what happened.

Signer identity is self-asserted. The signer.name and signer.owner fields are set by the agent. There's no external identity registry (yet) to verify that "demo-bot" actually belongs to "willamhou."

Try It

pip install signet-auth
# or
npm install @signet-auth/core @signet-auth/mcp
# or
cargo install signet-core
Enter fullscreen mode Exit fullscreen mode

GitHub: github.com/Prismer-AI/signet

Apache-2.0 + MIT dual license.


Questions about the design decisions, crypto choices, or roadmap? I'm @willamhou on Twitter/X.

Top comments (0)