A lightweight, Rust-based secrets vault designed for AI agents and automated pipelines. Store API keys and configuration securely, discover which secrets your project needs, and inject them at runtime — without ever hardcoding secrets in source code.
┌──────────────────────────────────────┐
│ cortex-server │
admin API │ · KEK in mlock'd memory (operator) │
Admin ─────────────►│ · per-row DEKs wrapped by KEK │
(curl / API) │ · authenticates agents (JWT) │
│ · issues project tokens │
└───────────────┬──────────────────────┘
│ ② project_token
│ ③ env vars
│
┌───────────────────────────────────┼────────────────────────┐
│ Agent │ │
│ (autonomous pipeline) │ │
│ ▼ │
│ ① cortex-cli daemon login ┌────────────────────┐ │
│ ──────────────────────────► cortex-daemon │ │
│ │ · holds project │ │
│ ④ cortex-cli run ─socket─►│ tokens │ │
│ │ · attests binary │ │
│ │ · spawns child │ │
└────────────────────────────┴───────┬────────────┘─────────┘
│
exec() with env vars injected
│
▼
┌─────────────────────┐
│ Project Process │
│ python main.py │
│ node app.js … │
│ │
│ OPENAI_API_KEY=... │
│ DB_PASSWORD=... │
│ AUTH_TOKEN=... │
└─────────────────────┘
Flow:
- Admin pre-loads project secrets into
cortex-servervia the admin API - Operator runs
cortex-cli daemon loginonce on each agent host (OAuth 2.0 device-grant) and startscortex-daemon. The daemon keeps the agent's Ed25519 private key in mlock'd memory and registers an ephemeral attestation public key at/daemon/attest(binary SHA-256 must be on the allowlist). - Daemon signs
/agent/discoverwith the agent's Ed25519 key on demand; the first request for a new(agent_id, project)pair waits inpending_grantsuntil an admin approves it on the dashboard, after which auto-approval runs for 30 days as long as the requested env-key set is a subset. - Agent calls
cortex-cli run --project <name> --url <server> -- <cmd>, which sends one line of JSON to~/.cortex/agent.sock. The daemon fetches secrets, spawns the child with them injected, and returns the exit code — neither the project token nor the secret values traverse the socket.
- Agents never touch secret values — secrets flow directly from
cortex-serverinto the process environment viaexec(); agent code never reads or stores them - No human intervention per task — agents autonomously obtain and inject secrets across any number of projects and tasks without requiring manual input for each run (except first-time project secrets access approval)
- Fully autonomous secret injection — unattended agent pipelines retrieve all required credentials on demand at runtime; no operator in the loop
- Secrets never written to disk — API keys, database credentials, tokens, and passwords exist only in process memory as environment variables; nothing is persisted to files
- Daemon mode is the only path —
cortex-cli runno longer accepts--token/--agent-id/--priv-key-file.cortex-daemon(#16) holds the agent's Ed25519 private key + project tokens in mlock'd memory, exposes~/.cortex/agent.sock(mode 0600, SO_PEERCRED-gated), and proves its identity to the server with an ephemeral Ed25519 attestation key (#17). Peers can requestrunoperations but cannot extract raw secret material.
brew tap davideuler/cortex-auth
brew install cortex-authNote: The Homebrew tap provides pre-built binaries for Apple Silicon (M1/M2/M3) only. macOS Intel users should build from source.
Download from the GitHub Releases page.
VERSION=v0.1.2
# Detect platform
case "$(uname -s)-$(uname -m)" in
Darwin-arm64) TARGET=aarch64-apple-darwin ;;
Linux-x86_64) TARGET=x86_64-unknown-linux-musl ;;
Linux-aarch64) TARGET=aarch64-unknown-linux-musl ;;
*) echo "No pre-built binary for this platform — see Build from source below"; exit 1 ;;
esac
ARCHIVE="cortex-auth-${VERSION}-${TARGET}"
curl -fLO "https://github.com/davideuler/cortex-auth/releases/download/${VERSION}/${ARCHIVE}.tar.gz"
tar xzf "${ARCHIVE}.tar.gz"
sudo mv "${ARCHIVE}/cortex-server" "${ARCHIVE}/cortex-cli" /usr/local/bin/
rm -rf "${ARCHIVE}" "${ARCHIVE}.tar.gz"| Platform | Pre-built binary |
|---|---|
| macOS Apple Silicon (M1/M2/M3) | cortex-auth-v0.1.2-aarch64-apple-darwin.tar.gz |
| macOS Intel | — build from source |
| Linux x86_64 | cortex-auth-v0.1.2-x86_64-unknown-linux-musl.tar.gz |
| Linux ARM64 | cortex-auth-v0.1.2-aarch64-unknown-linux-musl.tar.gz |
Requires Rust (stable).
git clone https://github.com/davideuler/cortex-auth.git
cd cortex-auth
cargo build --release
# Binaries at: target/release/cortex-server target/release/cortex-cli# Generate the admin token (the KEK is derived from an operator passphrase you type at startup)
ADMIN_TOKEN=$(openssl rand -hex 16)
# Start the server. It boots SEALED and prompts for the KEK operator password
# on stdin. After the password unwraps the on-disk sentinel the server
# transitions to UNSEALED and starts listening on :3000.
DATABASE_URL=sqlite://cortex-auth.db \
ADMIN_TOKEN=$ADMIN_TOKEN \
cortex-server
# [cortex-server SEALED] Enter KEK operator password: ********
# (Headless deployments — supply the password via env var instead of stdin.)
# CORTEX_KEK_PASSWORD='strong-passphrase' cortex-server
# In another terminal — add a secret
curl -X POST http://localhost:3000/admin/secrets \
-H "Content-Type: application/json" \
-H "X-Admin-Token: $ADMIN_TOKEN" \
-d '{"key_path":"openai_api_key","secret_type":"KEY_VALUE","value":"sk-your-key"}'
# Discover project secrets (authenticate with agent_id + Ed25519 signature).
# `cortex-cli sign-proof` reads ~/.cortex/agent-<id>.key created by
# `cortex-cli gen-key` and prints {"ts","nonce","auth_proof"}.
# One-time on the agent host: register the daemon via OAuth 2.0 device-grant.
cortex-cli daemon login --url http://localhost:3000
# Visit the printed dashboard URL and approve the user_code.
# Start the daemon. It generates an ephemeral attestation key, registers
# its binary SHA-256 with /daemon/attest, and listens on
# ~/.cortex/agent.sock with SO_PEERCRED + PR_SET_DUMPABLE + mlockall.
cortex-daemon &
# Launch your app — no token, no agent_id, no priv-key-file. The daemon
# discovers, rotates, and injects everything transparently. The first
# discover for a new (agent_id, project) pair is gated by an admin
# approval flow (see "Pending Grants" in docs/USAGE.md).
cortex-cli run \
--project my-app --url http://localhost:3000 \
-- python3 main.py| Component | Description |
|---|---|
cortex-server |
HTTP API server (axum + SQLite). Stores secrets encrypted with AES-256-GCM. |
cortex-cli |
CLI launcher that fetches secrets and exec()s your process with them injected as env vars. |
The cortex-skills/ directory contains a ready-to-use skill following the
Agent Skills open standard — the same
SKILL.md format works across all major agent frameworks. Once installed, your agent
autonomously authenticates with Cortex and injects secrets without any human prompting.
| Agent | Skills directory | Docs |
|---|---|---|
| Claude Code | ~/.claude/skills/ (global) · .claude/skills/ (project) |
Extend Claude with skills |
| Codex CLI | ~/.codex/skills/ (global) · .agents/skills/ (project) |
Agent Skills – Codex |
| OpenCode | ~/.opencode/skills/ (global) · .opencode/skills/ (project) |
Agent Skills · OpenCode |
| OpenClaw | ~/.openclaw/skills/ (global) · skills/ (workspace) |
Skills – OpenClaw |
| Hermes Agent | ~/.hermes/skills/ (local) · ~/.agents/skills/ (shared) |
Skills System · Hermes |
# 1. Clone (or use your existing copy of) cortex-auth
git clone https://github.com/davideuler/cortex-auth.git /tmp/cortex-auth
# 2. Install the skill for your agent — pick one:
# Claude Code (global)
cp -r /tmp/cortex-auth/cortex-skills ~/.claude/skills/cortex-secrets
# Codex CLI (global)
cp -r /tmp/cortex-auth/cortex-skills ~/.codex/skills/cortex-secrets
# OpenCode (global)
cp -r /tmp/cortex-auth/cortex-skills ~/.opencode/skills/cortex-secrets
# OpenClaw (global)
cp -r /tmp/cortex-auth/cortex-skills ~/.openclaw/skills/cortex-secrets
# Hermes Agent (local)
cp -r /tmp/cortex-auth/cortex-skills ~/.hermes/skills/cortex-secretsTo keep the skill in sync with future cortex-auth updates, use symlinks instead of copying:
ln -sf /tmp/cortex-auth/cortex-skills ~/.claude/skills/cortex-secretsFor project-scoped installation (committed alongside your code), copy into the
agent-specific project directory (e.g. .claude/skills/cortex-secrets/).
- Design & Architecture — System design, security model, data flow
- Usage Guide — Admin API examples, cortex-cli usage, production setup
- Open Questions — Items needing stakeholder decisions
- Roadmap — Security hardening, features, optimizations
# Run all tests
cargo test --workspace
# Check for lint issues
cargo clippy --workspace -- -D warnings
# Build release binaries
cargo build --releaseCortexAuth uses a two-tier key hierarchy. The KEK (Key Encryption Key) lives only in the operator's head and in process memory; the DB never stores it. Every secret is encrypted with its own random DEK (Data Encryption Key); the DEK is then wrapped with the KEK and stored beside the ciphertext. The plaintext DEK is zeroized as soon as the row is written.
1. cortex-server starts in SEALED state — no listener yet
2. Operator types the KEK password (stdin) or supplies CORTEX_KEK_PASSWORD
3. Server derives KEK = Argon2id(password, salt_from_DB) and mlocks it
4. Server reads the sentinel ciphertext, decrypts it with KEK, compares to
the known plaintext → proves the password matches the prior KEK
5. Server transitions to UNSEALED and binds :3000
A wrong password fails the sentinel check and the process exits without ever opening the listener. On first boot the sentinel is generated and stored automatically.
plaintext = "sk-abc123..."
step 1 DEK = random_bytes(32)
step 2 ciphertext = AES-256-GCM(DEK, nonce_d, plaintext)
step 3 wrapped_DEK = AES-256-GCM(KEK, nonce_k, DEK)
step 4 INSERT INTO secrets(ciphertext, wrapped_DEK, kek_version, ...)
step 5 zeroize(DEK, plaintext)
step 1 SELECT ciphertext, wrapped_DEK
step 2 DEK = AES-256-GCM-Decrypt(KEK, nonce_k, wrapped_DEK)
step 3 plaintext = AES-256-GCM-Decrypt(DEK, nonce_d, ciphertext)
step 4 return plaintext; zeroize intermediate DEK copies
Compromise of the DB alone does not leak any secret — the wrapped DEKs are useless without the KEK, which is only ever in the running server's memory.
Namespaces partition secrets, agents, projects, and configs. An agent registered in namespace
prod only sees secrets in prod; the same agent ID in staging sees a different set. Manage
namespaces from the dashboard (Namespaces tab) or the admin API:
curl -X POST http://localhost:3000/admin/namespaces \
-H "X-Admin-Token: $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"staging","description":"Pre-prod environment"}'
# Tag a secret/agent at create-time:
curl -X POST http://localhost:3000/admin/secrets \
-H "X-Admin-Token: $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"key_path":"openai_api_key","secret_type":"KEY_VALUE","value":"sk-...","namespace":"staging"}'The default namespace is created automatically and cannot be deleted. A namespace that still
owns secrets/agents/projects refuses deletion.
Each project_token carries an explicit scope — the set of key_paths the
caller is allowed to read. The scope is computed from the .env file the
agent submits to /agent/discover and frozen on the projects row at issue
time. /project/secrets filters its response to that frozen scope, so a leaked
token can only ever read the secrets it was originally minted for. The default
TTL is 14 days (1209600 seconds); admins can revoke the token early via
POST /admin/projects/<name>/revoke.
Mark a secret as a decoy at create time:
curl -X POST http://localhost:3000/admin/secrets \
-H "X-Admin-Token: $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"key_path":"legacy_aws_root_key","secret_type":"KEY_VALUE",
"value":"AKIA-FAKE-DO-NOT-USE","is_honey_token":true}'A honey-token is never returned to a legitimate caller. Any read attempt is a
100% attack signal: the calling project's token is revoked immediately, an
alarm-status row is written to the audit log, the response is a generic
401, and an outbound notification is dispatched to every enabled channel
(see below).
Honey-token alarms and Shamir recovery boots fan out to every enabled
notification channel managed under the dashboard's Notifications tab
(or POST /admin/notification-channels):
| Channel | Transport | Config |
|---|---|---|
| Slack | incoming webhook | {"webhook_url":"https://hooks.slack.com/..."} |
| Discord | incoming webhook | {"webhook_url":"https://discord.com/api/webhooks/..."} |
| Telegram | Bot API | {"bot_token":"...","chat_id":"..."} |
himalaya-cli (when on PATH) |
{"to":"oncall@example.com","account":"..."} |
Channel configs are envelope-encrypted under the KEK, so a DB-only compromise leaks nothing about who gets paged.
Every agent authenticates with an Ed25519 public key. The agent generates
the keypair locally with cortex-cli gen-key, uploads only the public key,
and proves identity at /agent/discover by signing
ts | nonce | agent_id | /agent/discover:
# 1. Generate a keypair (private key persisted at ~/.cortex/agent-<id>.key, mode 0600)
cortex-cli gen-key --agent-id my-agent
# stdout: <base64url public key>
# 2. Register the agent with that public key
curl -X POST http://localhost:3000/admin/agents \
-H "Content-Type: application/json" -H "X-Admin-Token: $ADMIN_TOKEN" \
-d '{"agent_id":"my-agent","agent_pub":"<base64url-pubkey>"}'
# 3. Sign an auth_proof at run time
cortex-cli sign-proof --agent-id my-agent --priv-key-file ~/.cortex/agent-my-agent.key
# stdout: {"ts":1714248000,"nonce":"...","auth_proof":"<base64url-sig>"}Replay protection: the request ts must be within ±5 minutes of the server
clock. The private key never leaves the agent's machine — a DB compromise
exposes only public keys.
POST /agent/discover now accepts signed_token: true, which mints an
EdDSA-signed JWT project token alongside the legacy random one:
Verifiers fetch the public key from GET /.well-known/jwks.json (keyed by
kid so old tokens stay verifiable across rotations). Revocation is
enforced via the revoked_token_jti table.
If the operator password is lost, the running KEK can be split into n shares
with threshold m and reconstructed at boot. Generate shares once from the
dashboard's Shamir Recovery tab (or POST /admin/shamir/generate) and
distribute them to operators — the server does not retain a copy.
# Recovery boot: prompts for `threshold` shares interactively on stdin.
CORTEX_RECOVERY_MODE=1 \
CORTEX_RECOVERY_THRESHOLD=3 \
ADMIN_TOKEN=$ADMIN_TOKEN \
cortex-server
# [cortex-server SEALED (RECOVERY MODE)] — awaiting Shamir shares on stdin
# share 1 of 3: ********
# share 2 of 3: ********
# share 3 of 3: ********A successful recovery boot writes an alarm-status row to the audit log
(action="recovery_boot") and dispatches notifications to every enabled
channel.
A long-running cortex-daemon process can hold the session for an agent so
unattended pipelines never see raw secret material. Login uses the OAuth 2.0
Device Authorization Grant (RFC 8628):
# 1. Start the daemon (listens on ~/.cortex/agent.sock)
cortex-daemon &
# 2. Trigger device login — prints a user_code
cortex-cli daemon login --url http://localhost:3000
# [cortex-cli] visit http://localhost:3000/device and approve user_code: ABCD-1234
# 3. An admin approves the user_code via the dashboard's `Devices` tab
# (or POST /admin/web/device/approve), binding it to an agent_id.
# 4. The daemon now holds an Ed25519-signed access token for the agent.
cortex-cli daemon statusThe daemon scaffolding implements the basic socket protocol (status /
run); full attestation and the inject_template / ssh_proxy socket
verbs are tracked in docs/UNCERTAINTIES.md #17.
Every audit row is HMAC-SHA256 chained to the previous row using a key derived
from the KEK (HKDF-style, fixed domain separator). Each row stores
prev_hash || entry_mac; the running tail MAC lives in audit_mac_state.
Any deletion, re-order, or rewrite of an audit row breaks the chain and is
detectable by replaying entries.
Rows also record optional caller metadata (caller_pid,
caller_binary_sha256, caller_argv_hash, caller_cwd, caller_git_commit,
source_ip, hostname, os) populated from X-Cortex-Caller-* request
headers. Missing fields stay NULL — the chain MAC covers them either way.
Audit logs are auto-deleted after 60 days.
- AES-256-GCM with a unique nonce per write for both DEK→body and KEK→DEK steps
- Project tokens hashed with SHA-256 (the raw token is never stored). EdDSA-signed JWT tokens are also supported (#14) when the caller passes
signed_token: trueto/agent/discover; the server's public key is published atGET /.well-known/jwks.json. - Admin operations protected by static
ADMIN_TOKEN(#18 multi-user RBAC tracked as deferred) /agent/discoverauthenticates agents either via Ed25519 signature (#13) when anagent_pubis registered, or via legacy HMAC-SHA256 JWT — no separate session token issued- Project access via one-time-issued
project_token(must be saved — cannot be recovered) or via the EdDSA-signed JWT alternative cortex-cliusesexec()— secrets never visible to a parent processcortex-daemon(#16) holds the session over a Unix socket so unattended pipelines never see raw secret material- KEK rotation:
POST /admin/rotate-key {"new_kek_password": "..."}re-wraps every DEK with the new KEK and bumpskek_version. Body ciphertexts are untouched. - KEK recovery (#15):
CORTEX_RECOVERY_MODE=1boots from m Shamir shares typed on stdin instead of the operator password - TLS terminated in-process when
TLS_CERT_FILE+TLS_KEY_FILEare set (rustls) - Outbound notifications (#12 / #15): honey-token alarms and recovery boots fan out to Slack / Discord / Telegram / email channels managed in the dashboard
The current build covers envelope encryption, namespaces, scoped tokens,
honey tokens, tamper-evident audit logs, outbound notifications, Ed25519
agent identity, Ed25519-signed project tokens with a JWKS endpoint,
Shamir m-of-n unseal recovery, and the OAuth 2.0 device-authorization
flow with a basic cortex-daemon. The remaining gap from
UPDATED_DESIGN.md is daemon attestation (#17) plus the
multi-user RBAC for admins (#18) and the verify-audit CLI (#11) — see
docs/UNCERTAINTIES.md.
{ "iss": "cortex-auth", "sub": "<project_name>", "aud": "cortex-cli", "iat": 1714248000, "exp": 1715457600, "jti": "<uuid>", "scope": ["openai_api_key", ...], "namespace": "default", "project_id": "<project_name>" }