A self-contained, replicated secrets vault: a single Rust binary that stores secrets encrypted at rest on local disk and exposes a REST API to create, read, update, delete, verify, and automatically rotate them.
Secrets are sealed with envelope encryption — a per-secret data key (DEK) protected by AWS KMS, with the secret bytes sealed under XChaCha20-Poly1305. Only ciphertext, the wrapped DEK, and a nonce are ever written to disk; plaintext never lands on disk.
Storage is an embedded redb key-value store (one file per node). High availability comes from openraft: the vault runs as a Raft cluster, writes go through consensus to the leader and replicate to a quorum, and reads are served locally from any node. There is no external database to operate.
Comparable to supabase/vault and OpenBao (KMS-backed envelope encryption + API + rotation) — this project packages that model as a small, dependency-free, self-replicating cluster.
Status: in production
- Encryption at rest, KMS-backed. Envelope encryption: AWS KMS wraps a
256-bit data key (via
GenerateDataKey/Decrypt); the secret is sealed with XChaCha20-Poly1305. Disk stores only{wrapped_dek, nonce, ciphertext, aad}. - Embedded storage (redb). Each node owns a single redb file — no Postgres, no external services. The Raft log, snapshots, and the secret state all live in that one embedded store.
- Raft replication & failover (openraft). Writes are committed through Raft
to the leader and replicated to a quorum; leader election provides automatic
failover. Reads are served from the local node (optionally linearizable
through the leader via
VAULT_READ_CONSISTENCY=linearizable). - Two secret kinds.
manual— set and changed explicitly through the API.automatic— rotated on a schedule by a background worker (running only on the leader). Old versions stay valid for a configurable grace period.
- REST API.
POST/GET/PUT/DELETE /v1/secrets, plus/rotateand a constant-time/verifyendpoint for grace-window validation. - IP allowlist for reads. Secret reads are permitted only from an
IPv4/CIDR allowlist (
VAULT_ALLOWED_IPS). Fail-closed: an empty allowlist denies all reads. - Role-based access control (RBAC).
create/update/delete/rotateand role/token administration require a bearer token. Each token maps to a role whose rules areaction × secret-path glob(e.g. apaymentrole scoped tostripe/*), with a built-inadminsuperuser role. Tokens are stored only as SHA-256 fingerprints and checked in constant time. - Brute-force lockout. Auth/authz failures are counted per client IP
(per node); after
VAULT_AUTH_MAX_FAILURESthe IP is locked out (HTTP429) forVAULT_AUTH_LOCKOUT_SECS. - KMS-outage resilience. Unwrapped data keys are cached in memory for
VAULT_DEK_CACHE_TTL_SECS, and KMS calls are retried with exponential backoff up toVAULT_KMS_MAX_RETRIES. - KMS key rotation. Automatic CMK rotation is transparent (each version
records the key that wrapped it). When
VAULT_KMS_REWRAP_ENABLEDis on, a leader-gated worker also detects rotations (kms:ListKeyRotations) and re-wraps every live version's data key onto the new key material viakms:ReEncrypt— secret bytes are never re-encrypted and the DEK never leaves KMS. Force/inspect withPOST/GET /v1/admin/kms/rewrap. - Audit log. Every operation is recorded (actor, client IP, action,
outcome) in a local
audit_logtable per node. - Backup & restore. Admin-only
GET /v1/backupexports a consistent, point-in-time snapshot of every data table (secrets, versions, roles, tokens, audit); secret values stay encrypted — only sealed records leave the store, never plaintext.POST /v1/restorerebuilds a node from such a snapshot. No external tooling, cron, or background worker — just two API calls. - Defense in depth. Application-enforced access invariants, version-bound AEAD associated data (a ciphertext can't be replayed under another secret name or version), and zeroized key material in memory.
| Crate / dir | Kind | Purpose |
|---|---|---|
crates/hyperion-vault-core |
lib | Pure-Rust security core: AEAD envelope, IP allowlist, token auth, rotation policy. No storage, no network. |
crates/hyperion-vault-api |
bin | The REST API service (axum): handlers, IP/token guards, the redb store (store/), the Raft layer (raft/), KMS, rotation worker. |
crates/hyperion-vault |
lib | Umbrella crate re-exporting the security core. |
docs/ |
— | Architecture, decisions, threat model, API and security docs. |
docker/ |
— | Single-binary node image + N-node Raft cluster compose + WireGuard overlay + e2e overlay. |
The storage layer is built around a ports & adapters seam: the service layer
depends only on the VaultReader / VaultWriter traits (store/ports.rs). The
single-node RedbStore and the Raft-backed RaftStore are interchangeable
adapters; both funnel every mutation through one deterministic apply_command.
See docs/DECISIONS.md.
┌─ vault1 ─┐ ┌─ vault2 ─┐ ┌─ vault3 ─┐
client ──>│ API │ │ API │ │ API │ reads: local redb
│ redb │<=>│ redb │<=>│ redb │ writes: Raft → leader
└──────────┘ └──────────┘ └──────────┘ → quorum → apply
openraft consensus (HTTP RPC)
- The leader is the equivalent of a primary: it accepts writes, replicates the log to followers, and runs the rotation worker. A follower transparently forwards writes to the current leader.
- Each node persists everything to its own redb file (
VAULT_DB_PATH). - Nodes find each other through
VAULT_PEERS(anid=host:portcluster map, identical on every node); a node's own identity isNODE_ID.
cargo test --workspace
# fuzz tests (nightly; see fuzz/README.md)
cargo +nightly fuzz run crypto_roundtrip -- -max_total_time=60
cargo +nightly fuzz run --features api raft_rpc_decode -- -max_total_time=60
make fuzz-build # check every target still compiles
make fuzz BUDGET=60 # sweep all 22 targets, 60s each# Bring up a 3-node Raft cluster (KMS in local dev mode)
# BE SURE TO CHANGE DEFAULT VAULT_LOCAL_MASTER_KEY and remove it from production .env after creating your secrets!
cd docker && cp .env.example .env && docker compose up --buildAPIs listen on localhost:8200 (vault1), :8201 (vault2), :8202 (vault3);
Raft RPC runs on the internal :7400 of each node. See
docs/API.md for full request/response examples.
# Mint a first admin token by setting VAULT_BOOTSTRAP_TOKEN on the nodes,
# then use it (the dev compose seeds 'dev-admin-token-change-me' via the e2e overlay):
TOKEN=dev-admin-token-change-me
# Create a manual secret
curl -sS -X POST localhost:8200/v1/secrets \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"name":"db/password","kind":"manual","value":"s3cr3t"}'
# Read it back (must come from an allowlisted IP)
curl -sS localhost:8200/v1/secrets/db/password
# Create an auto-rotating secret with a 24h interval and 1h grace
curl -sS -X POST localhost:8200/v1/secrets \
-H "Authorization: Bearer $TOKEN" \
-H 'content-type: application/json' \
-d '{"name":"svc/api-key","kind":"automatic","rotation_interval_secs":86400,"grace_period_secs":3600}'{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"kms:GenerateDataKey",
"kms:Encrypt",
"kms:Decrypt",
"kms:DescribeKey",
"kms:ListKeyRotations",
"kms:ReEncryptFrom",
"kms:ReEncryptTo"
],
"Resource": "arn:aws:kms:[region]:[account-id]:key/[key-id]"
}
]
}| Variable | Default | Description |
|---|---|---|
NODE_ID |
1 |
This node's id (Raft node id + audit label). The only value that differs per node. |
VAULT_PEERS |
(empty) | Cluster map id=host:port,… of the Raft addresses, identical on every node. One entry (or empty) ⇒ single-node, Raft disabled; two or more ⇒ replicated cluster. |
VAULT_RAFT_BIND |
0.0.0.0 |
Address the node-to-node port binds to. Set it to this node's private address so the port is not reachable from any other interface. |
VAULT_CLUSTER_TOKEN |
— | Shared credential sent on every node-to-node request. Same value on every node. |
VAULT_CLUSTER_AUTH_REQUIRED |
false |
Refuse node-to-node requests that do not carry VAULT_CLUSTER_TOKEN. Set the token on every node first, then turn this on everywhere; the other order splits the cluster! |
VAULT_RAFT_PEER_CHECK |
false |
Refuse node-to-node connections whose source address is not one of the addresses in VAULT_PEERS. Leave it off if peers reach each other through address translation. |
VAULT_API_PORT |
8200 |
Public REST API bind port (0.0.0.0:PORT). The external address is whatever your load balancer / port mapping exposes. |
VAULT_DB_PATH |
vault.redb |
Path to this node's redb file. |
VAULT_BOOTSTRAP_TOKEN |
— | If set, seeds a bootstrap-admin token (mapped to the built-in admin role) on startup. Set the same value on every node for a fresh cluster, then rotate it. |
VAULT_ALLOWED_IPS |
(empty → deny all reads) | Comma-separated IPv4 / CIDR allowed to read secrets. |
VAULT_TRUST_PROXY |
false |
Trust X-Forwarded-For for the client IP (only behind a trusted proxy). |
VAULT_READ_CONSISTENCY |
local |
local (read from this node) or linearizable (route reads through the leader). |
VAULT_KMS_MODE |
aws |
aws (production) or local (dev only). |
VAULT_KMS_KEY_ID |
— | AWS KMS key id/ARN (required for aws). |
VAULT_LOCAL_MASTER_KEY |
— | base64 32-byte master key for local mode. |
VAULT_ROTATION_POLL_SECS |
15 |
How often the leader scans for due rotations. rotation_interval_secs and grace_period_secs on a secret are capped at 100 years. |
VAULT_PG_TARGET_HOSTS |
(empty → the hosts already used by stored pg_replica targets) |
Comma-separated host:port list of the Postgres endpoints a rotation target is allowed to connect to. A target naming any other host is rejected at create time and refused again at rotation time. When unset, the node seeds the list at startup from the targets already in its database, so an upgrade keeps rotating the secrets it already had; set it explicitly to stop depending on that. |
VAULT_PG_TARGET_TLS |
prefer |
verify-full = require TLS and verify the server certificate chain and hostname; prefer = use TLS when the Postgres server offers it, plaintext when it does not; disable = always plaintext. |
VAULT_PG_TARGET_CA_FILE |
(system trust store) | PEM file of CA certificates used to verify the Postgres server certificate. |
VAULT_DEK_CACHE_TTL_SECS |
300 |
TTL of the in-memory decrypted-DEK cache. 0 disables. |
VAULT_KMS_MAX_RETRIES |
5 |
Retry KMS calls up to N times with exponential backoff. 0 disables. |
VAULT_KMS_REWRAP_ENABLED |
false |
Re-wrap data keys onto new CMK material after a KMS key rotation. Enable only after every node runs a build that understands it. |
VAULT_KMS_REWRAP_POLL_SECS |
86400 |
How often the leader polls kms:ListKeyRotations for a new rotation. |
VAULT_KMS_REWRAP_MAX_PER_SEC |
10 |
Re-wrap pacing (KMS ReEncrypt + Raft writes per second). 0 disables pacing. |
VAULT_AUTH_MAX_FAILURES |
5 |
Failed attempts before lockout, counted per sending address for auth/authz failures and per sending address and secret name for /verify mismatches. The address is always the TCP peer, never X-Forwarded-For. 0 disables. |
VAULT_AUTH_LOCKOUT_SECS |
900 |
How long a locked-out IP stays locked. |
VAULT_AUTH_WINDOW_SECS |
300 |
Window over which failures accumulate. |
VAULT_TOKEN_TOUCH_SECS |
300 |
Shortest gap between writes of a token's last_used_at. On a cluster each write goes through Raft, so this bounds that cost; 0 writes on every authenticated request. |
For AWS mode, AWS credentials and region come from the standard AWS SDK
environment (AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or an
instance role) — not from VAULT_* variables. The key's IAM policy needs only
kms:GenerateDataKey and kms:Decrypt (add kms:ListKeyRotations and
kms:ReEncryptFrom/kms:ReEncryptTo when VAULT_KMS_REWRAP_ENABLED=true).
This is security-critical software. Read docs/SECURITY.md
and docs/THREAT_MODEL.md before deploying.
- docs/DECISIONS.md - decisions
- docs/ARCHITECTURE.md - architecture
- docs/SECURITY.md - security
- docs/THREAT_MODEL.md - threat model
- docs/API.md - REST API
- docs/INTEGRATION.md - ciqadamq / pg_replica / server-backend integration (A→Z)
- docs/WIREGUARD.md - optional admin access over WireGuard
GPL-3.0-or-later. See LICENCE.