- Nix 98.8%
- Makefile 1.2%
| examples | ||
| nixos | ||
| src | ||
| .air.toml | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| lisette.toml | ||
| Makefile | ||
| README.md | ||
sec — a hand-rolled secrets manager
⚠️ Vibe-coded. This repository was built by an AI assistant (RingOfStormsBot) with human direction, not hand-written by a person. Treat it accordingly: read the code before you trust it, and don't deploy it to protect anything you can't afford to lose without your own review.
A small, single-binary secrets manager for a personal NixOS fleet. One binary, three modes: a server with an admin UI, a per-host agent, and a human CLI. KV only — store, fetch, and rotate secrets with Nix-declared paths and prefix-based authorization.
Written entirely in Lisette. No hand-written Go.
Shape
| Mode | What it is |
|---|---|
sec serve |
The server. Owns the encrypted SQLite DB and the admin UI |
sec agent |
The per-host daemon. Mints Zitadel assertions, batch-fetches secrets, writes them atomically, and restarts dependent services |
sec get <path> [field] |
The human CLI. Local cache first, server as fallback |
Everything the fleet needs is three HTTP calls: login, batch-fetch, and single-fetch.
Quick start
nix develop # or: direnv allow
make check # type-check the Lisette sources
make build # -> ./bin/sec
./bin/sec keygen > /tmp/db.key && chmod 400 /tmp/db.key
./bin/sec serve \
--db /tmp/sec.db --key /tmp/db.key \
--state examples/desired-state.json --listen 127.0.0.1:8300
Then curl -s localhost:8300/healthz | jq and open
http://localhost:8300/ui.
Local development (no-auth mode)
Driving the admin UI normally needs a working Zitadel client. For local work you can skip authentication entirely:
./bin/sec serve --dev \
--db tmp/sec.db --key tmp/db.key \
--state examples/desired-state.json --listen 127.0.0.1:8300
# or, equivalently
SEC_DEV_MODE=1 ./bin/sec serve --db tmp/sec.db ...
make dev (air) already passes --dev, so the hot-reloading server is
usable without an IdP.
Every request is then served as dev-mode:dev@localhost holding the
admin role. This disables all authentication. Four things make it
hard to do by accident:
- It refuses to start against anything production-shaped. The
database, key and desired-state paths must all be non-default, and
the listen address must be a loopback literal (
127.0.0.1,::1— a hostname likelocalhostis refused, because DNS is attacker-influenced in a way a literal is not). Any violation exits non-zero with a message naming the interlock. - The bind is forced to loopback regardless of
--listen. - It is resolved once at startup, into
Server.dev_mode. No handler reads the environment, so nothing in a request can turn it on. - It is loud: a startup banner, a red bar on every UI page, a
sec_dev_mode 1gauge in/metrics,dev_mode: truein/healthz, and adev-mode:prefix on every audit principal andupdated_by.
Two things it deliberately does not do:
- It does not relax authorization. The synthetic principal is an
admin, but
write_secretstill refuses to create a path Nix has not declared. Dev mode skips proving who you are; it does not skip the reconciler's invariants. - It does not exercise authentication. The OIDC, JWKS and JWT paths are entirely bypassed, so a working dev run tells you nothing about whether login works. Test those against a real IdP before deploying.
The .envrc sets scratch SEC_DB/SEC_KEY/SEC_STATE/SEC_LISTEN
but leaves SEC_DEV_MODE commented out on purpose: direnv exports into
every process started in the directory, and "my shell silently has auth
off" is not a state you should reach by accident. Prefer the one-shot
SEC_DEV_MODE=1 ./bin/sec serve form.
Layout
flake.nix builds `lis`, the `sec` package, and the devshell
lisette.toml project manifest + pinned Go deps
Makefile lis build -> go build -> ./bin/sec
src/
main.lis arg parsing -> serve | agent | get | ...
server.lis routing, startup, signals
api.lis /v1/login, /v1/secrets, /v1/secret, /healthz, /metrics
auth.lis JWKS cache, token issue, the login handler
jwt.lis RS256 verify, claim checks
throttle.lis token buckets, lockout, replay cache
store.lis schema access, CRUD, versions, audit
crypto.lis HKDF, per-value AES-256-GCM, base64url
reconcile.lis desired-state.json -> one transaction
ui.lis the four admin screens
oidc.lis admin sign-in (authorization code + PKCE)
html.lis escaping and page chrome
dev.lis no-auth dev mode and its startup interlocks
static.lis CSS + a small hx-* driver, inlined
agent.lis assertion minting, batch fetch, atomic write, systemctl
cli.lis get / status / import / reconcile / keygen
schema.lis the SQL
support.lis time, HTTP, JSON, path and flag helpers
types.lis every struct and constant
nixos/
server.nix systemd unit, nginx vhost, desired-state wiring
agent.nix systemd unit, desired-state wiring, ~150 lines
examples/
desired-state.json what Nix generates for the server
agent.json what Nix generates for a host
The model
Nix declares what exists. The UI only fills in values.
The reconciler runs in-process at startup and on SIGHUP, in one
transaction:
- Declared
(path, field)with no row → insert aTODO:replace_mestub withis_stub = 1. - Declared
(path, field)with a row → touch nothing. Values are never overwritten by the reconciler. - Row under a
managedPrefixthat is not declared → copy tosecret_versionsas a tombstone, then delete. - Write an audit row. Swap the in-memory role table.
Failure is atomic and loud: the transaction rolls back, the server keeps
serving the previous config, and the unit fails so nixos-rebuild
surfaces it.
Roles and policies are not in the database. They come from the Nix-generated desired-state file and are held in memory. There is no way to grant yourself access by writing to the database — you would have to change git.
Authorization
Prefix matching. No policy language, no capability strings, no sudo.
Plus one hard rule: only the admin role may write, and only to paths
whose row already exists. You can change a value; you cannot create a
path. There is deliberately no root-equivalent credential anywhere.
Encryption at rest
Per-value AES-256-GCM, key derived from /sec-keys/db.key via
HKDF-SHA256, with the (path, field) pair bound in as additional
authenticated data so a ciphertext cannot be moved between rows.
This is option (b) from the design's §4.1. It keeps the pure-Go
modernc.org/sqlite build — no cgo, no C toolchain, no
CGO_ENABLED=1 — at the cost of leaving path names and metadata
plaintext, which is fine, because Nix already declares all of them in
git.
Verified: grep over the raw database finds no secret value; it does
find path names.
Be honest about what this buys. The key sits next to the data; anyone with root on the server has both. What it does buy is that a stolen disk, backup or snapshot is not a plaintext secret dump.
Abuse control
All of it runs before any storage access.
| Control | Value |
|---|---|
| Per-IP token bucket | 5/min, burst 10 |
| Per-principal bucket | 10/min |
| Global login bucket | 60/min |
| Failed-auth lockout | 10 fails / 15 min → 1h IP lock, persisted |
| Body size cap | 8 KiB on login |
| Constant-time responses | ~120 ms floor on every login outcome |
JWT jti replay cache |
5 min, in-memory |
| Fail-closed on JWKS outage | reject, don't allow |
Every rejection writes an audit row. /metrics exposes Prometheus
counters for login_ok, login_denied{reason}, throttled and
locked.
Admin UI
Server-rendered HTML, four screens: List (stubs first, TODO REPLACE
badge), Edit (a textarea per field), History (with restore), and a
filterable Audit table.
Values are write-mostly: everything shows •••••••• until an explicit
reveal, and revealing writes an audit row. Shoulder-surfing and
screenshots are the realistic leak.
Static assets are inlined as Lisette string constants rather than
go:embed — the design flagged embedding as the one unproven piece of
this stack, and a few KB of CSS plus a ~60-line hx-* driver does not
justify either an embed.FS or vendoring 50 KB of htmx.
The agent
One long-running unit that handles the full per-host provisioning cycle.
- Mints the Zitadel RS256 assertion in-process. No token file, no timer.
- One batched fetch of everything this host is entitled to. Boot-time provisioning goes from ~13 round trips to 2.
- The bearer token lives in memory and never touches disk.
- Writes are atomic:
.tmp→ chown → chmod →rename(2). A reader never sees a truncated key. - Calls
systemctl try-restart/startdirectly, debounced to one restart per unit per poll. is_stubis honoured: the agent refuses to overwrite a good cached value with aTODO:replace_meplaceholder.- On any network failure it keeps the cached files, logs, backs off and retries. A host that boots with the server down comes up with its last-known-good secrets — the property that makes a single node acceptable.
SIGHUP(systemctl reload sec-agent) means "refresh now".
Commands
sec serve Run the server and admin UI
sec agent [--once] Run the per-host daemon
sec get <path> [field] Read one secret (local cache first)
sec status Show a running server's health
sec reconcile Apply the desired-state file once
sec check-state [file] Validate a desired-state file and exit
sec import <file.json> Bulk-load [{path, field, value}]
sec keygen Print a fresh master key
NixOS
# Server, on one host
ringofstorms.secrets.server = {
enable = true;
domain = "secrets.joshuabell.xyz";
zitadel = { issuer = "https://sso.example"; projectId = "3443..."; };
roles = { /* see examples/desired-state.json */ };
secrets = { "machines/high-trust/nix2nix_2026-03-15".fields = [ "value" ]; };
managedPrefixes = [ "machines/high-trust/" ];
};
# Agent, on every host.
ringofstorms.secrets.agent = {
enable = true;
server = "https://secrets.joshuabell.xyz";
role = "machines-hightrust";
zitadel = { issuer = "https://sso.example"; projectId = "3443..."; };
secrets."nix2nix_2026-03-15" = {
remotePath = "machines/high-trust/nix2nix_2026-03-15";
softDepend = [ "some.service" ];
};
};
What this is not
KV only. No PKI, no transit, no dynamic secrets, no leases, no namespaces. Those are non-goals, permanently.
The real cost is that this is a few thousand lines in the root of trust without a long audit history. Mitigations: the scope is three endpoints, the attack surface is one RSA verify plus prefix matching, and the crypto is entirely stdlib — zero hand-rolled primitives.