Overview
An HTTP service that does two jobs:
- Verifies ed25519-signed requests. A caller signs
(method, path, timestamp, nonce, body-hash)with its private key. The server holds the matching public key in a file and answers{ valid: true | false }. Stateless. No session store. No token issuance. No expiry to manage. - Hands out secrets on demand. SSH private keys for
deploy/push, API tokens for outbound calls. IP allow-list is the
first gate; under
AUTH_MODE=enforce, the same ed25519 signature is required on top.
Designed as a sidecar for Consciousness Server and Cortex, but works standalone for any HTTP service that wants key-based authentication without standing up Vault, OAuth, or a JWT issuer.
Why this exists
If you run a small group of services or agents that talk HTTP to each other, your auth options today are bad in three different ways:
- Bearer tokens in headers. Now you have token issuance, token storage, token rotation, token revocation, refresh flows, and a token database to back up. Half of "auth" is now "token lifecycle".
- mTLS between every pair. Cleaner trust model, but every operator hits the same wall: certificate authority, intermediate certs, rotation, and a hard story for revocation.
- Shared secret in
.env. Works until the day it leaks — then everyone learns about it at the same time.
Signed requests sit in the middle. The agent's public key is a file on the verifier's disk. Removing the file revokes the agent. The signature is per-request, so there is no token to steal that grants ongoing access. The verifier holds no state about who signed what — only "is this signature valid for this canonical message".
This pattern is well-known (AWS SigV4, GitHub Apps JWT, SSH agent forwarding). BuildOnAI Key Server is the self-contained implementation of it.
Install
git clone https://github.com/build-on-ai/buildonai-key-server.git
cd buildonai-key-server
cp auth/allowed-clients.json.example auth/allowed-clients.json
# edit auth/allowed-clients.json: add the IPs your callers will use
docker compose up -d
curl http://localhost:3040/health Default port is 3040 — part of the
BuildOnAI ecosystem reserved range 3030–3050. Change
via KEY_SERVER_PORT.
Register an agent (ed25519)
# On the agent's host:
ssh-keygen -t ed25519 -C "agent1@$(hostname)" \
-f ~/.ssh/buildonai-agent1 -N ""
# Copy the PUBLIC key to the key-server host:
scp ~/.ssh/buildonai-agent1.pub \
operator@key-server-host:/opt/buildonai-key-server/keys/agents/agent1.pub
# Confirm the server picked it up:
curl http://key-server-host:3040/api/agents/identity
# {"agents": ["agent1"]}
Private key stays on the agent's host. It never leaves. Revocation
is rm keys/agents/agent1.pub — takes effect immediately,
there is no cache.
Three auth modes
AUTH_MODE is read once at startup. Three valid values
give you a safe migration path from unsigned to signed deployments.
| Mode | Behaviour | When to use |
|---|---|---|
off | Middleware no-op. Signatures not checked. | Single host, single user, no untrusted callers. Default. |
observe | Invalid signatures pass but the reason is logged to logs/auth-observe.log. | Migrating from unsigned to signed: turn this on, fix every caller whose request would be rejected, then graduate to enforce. |
enforce | Valid signatures pass. Everything else → 401 (or 503 if the verify lookup itself fails). | Multi-agent, shared host, anything you want auditable. |
The three-mode design is deliberate. A single boolean
AUTH=on/off kills the migration path — the day you flip
it on, half your agents break because somebody forgot to wire their
signing client. observe is the safe rehearsal step.
Concepts
Canonical message
Agent, gated service, and key-server must reconstruct the same bytes
to sign and verify. Five fields joined by literal \n (LF):
<METHOD>\n<PATH>\n<X-Timestamp>\n<X-Nonce>\nSHA256(body) SHA256(body) is hex-encoded. PATH is the
request path without query string. For a GET with no
body, SHA256("") = e3b0c442...52b855.
Verifying a signed request
A gated service (Consciousness Server, your API, your webhook
receiver) extracts the four headers + body hash from an incoming
request and POSTs them to /api/verify:
# A gated service forwards the agent's headers + body hash to /api/verify.
curl -s -X POST http://key-server-host:3040/api/verify \
-H 'Content-Type: application/json' \
-d '{
"agent_id": "agent1",
"method": "POST",
"path": "/api/notes/create",
"timestamp": "2026-05-31T10:00:00Z",
"nonce": "a7b3c1d9e2f64a18b3c1d9e2f64a18b3",
"signature": "base64-ed25519-sig-here",
"body_sha256": "e3b0c44298fc1c149afbf4c8996fb924..."
}'
# {"valid": true} Anti-replay
- Timestamp window:
now-300stonow+60s. Outside → rejected. - Nonce cache: each
X-Nonceaccepted at most once per 5 minutes (RedisSET NX EX 300). - Body binding: the canonical message includes
SHA256(body)— a captured request cannot be replayed against a different endpoint or with a tampered body.
Vault layout
The on-disk layout under keys/ is deliberately simple
— ls tells you what's there. Per-agent ed25519 public
keys live at keys/agents/<AGENT>.pub. SSH private
keys at keys/ssh/<name>; API tokens at
keys/<service>/api-key.txt. Set
chmod 600 on private material.
Audit log
Every request — verify, vault read, success or failure — appends one
line to logs/audit.log plus a JSONL entry to
logs/audit.jsonl. Self-rotates at 50 MB.
[2026-05-31T08:00:01Z] IP=10.0.0.20 ENDPOINT=/api/verify RESULT=VALID agent=agent1
[2026-05-31T08:00:03Z] IP=10.0.0.30 ENDPOINT=/api/verify RESULT=VALID agent=agent2
[2026-05-31T08:00:18Z] IP=10.0.0.30 ENDPOINT=/api/verify RESULT=INVALID reason=nonce_replayed
[2026-05-31T14:22:05Z] IP=10.0.0.99 ENDPOINT=/keys/ssh/git-deploy RESULT=FORBIDDEN IP_not_whitelisted
[2026-05-31T14:22:18Z] IP=10.0.0.20 ENDPOINT=/keys/ssh/../etc/passwd RESULT=REJECTED path_traversal_attempt Common use cases
Inter-service auth in a small monorepo
Each service holds its own ed25519 private key and signs outbound
requests. The verifier is one container. Removes the per-service
SHARED_SECRET env var that nobody rotates.
Webhook authentication
Your source signs the payload before sending; the receiver forwards
the headers to /api/verify. Cryptographic proof of
origin without a shared HMAC secret per source. Stolen pub key file?
Public — there is no shared secret to steal.
IoT device auth
Each device generates its own ed25519 keypair on first boot. Public
key registered out of band. Telemetry is signed per request. Stolen
device → rm its .pub file, telemetry stops
being accepted, the device's stored private key is now useless
against you.
Vault read (legacy, optional)
# Vault read under AUTH_MODE=off (IP allow-list only):
curl -s http://localhost:3040/keys/ssh/github-deploy > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
The vault endpoints (/keys/ssh/<name>,
/keys/api/<service>) inherit the same trust model
as /api/verify: IP allow-list always, ed25519 signature
additionally under AUTH_MODE=enforce.
API
Seven endpoints. The whole surface area.
| Method | Path | Purpose |
|---|---|---|
| GET | /health | Liveness probe |
| GET | /api/agents/identity | List registered agent ids |
| GET | /api/agents/identity/:agent | Public key + fingerprint for one agent |
| POST | /api/verify | Verify a signed request (called by gated services) |
| GET | /keys/ssh/:name | Fetch an SSH private key from the vault |
| GET | /keys/api/:service | Fetch an API token from the vault |
| GET | /audit | Last 100 audit-log entries |
Threat model
Defended against
- Replay on the same LAN — nonce + timestamp window.
- Forged origin from a foreign host — IP allow-list as layer one.
- Captured-then-replayed at a different endpoint — canonical message binds method + path + body hash.
- Compromised agent whose private key is now in attacker
hands —
rm keys/agents/<AGENT>.pubon the verifier. Takes effect immediately, no cache. - Accidental commit of secrets — shipped
.gitignoreexcludeskeys/,logs/, and the realauth/allowed-clients.json.
Not defended against (deliberate scope)
- Network eavesdropping on the request body. ed25519 is a signature scheme, not encryption. Wrap behind TLS (Caddy, nginx) if requests carry sensitive payloads.
- An attacker with root on the host. They can read private keys, the pub-key vault, and the audit log directly from disk.
- High-volume DoS on
/api/verify. The server rate-limits nothing; put it behind a reverse proxy if untrusted callers can reach it.
For deployments outside a trusted LAN/VPN, the obvious upgrades
are: terminate TLS at a reverse proxy, run behind a private network,
set tight OS file permissions on keys/, and rotate
agent keys on a schedule. The threat model is written for low-friction
LAN use; adjust as the perimeter widens.