buildonai-key-server v2.0.0 AGPL-3.0-only + Commercial

BuildOnAI Key Server

ed25519 signature-per-request authentication. No tokens to issue, no sessions to manage, no JWT to operate. The key is the identity.

Overview

An HTTP service that does two jobs:

  1. 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.
  2. 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

terminal
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)

terminal
# 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):

canonical message
<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:

terminal
# 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-300s to now+60s. Outside → rejected.
  • Nonce cache: each X-Nonce accepted at most once per 5 minutes (Redis SET 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.

logs/audit.log
[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)

any allow-listed host
# 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 handsrm keys/agents/<AGENT>.pub on the verifier. Takes effect immediately, no cache.
  • Accidental commit of secrets — shipped .gitignore excludes keys/, logs/, and the real auth/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.

Next steps