No description
  • Nix 98.8%
  • Makefile 1.2%
Find a file
2026-08-05 03:27:49 -05:00
examples add hm module 2026-08-05 02:38:06 -05:00
nixos agent: add ensureNewline option for trailing newline 2026-08-05 03:17:39 -05:00
src agent: add ensureNewline option for trailing newline 2026-08-05 03:17:39 -05:00
.air.toml Add asset versioning, immutable caching; use tmp/air 2026-08-04 17:19:11 -05:00
.gitignore sec: a hand-rolled secrets manager, written entirely in Lisette 2026-08-04 01:42:31 -05:00
flake.lock initial mvp 2026-08-04 13:44:17 -05:00
flake.nix apply-changes: take secrets as arg (like secrets-bao) to avoid recursion 2026-08-05 02:51:07 -05:00
lisette.toml Switch to key-based access with grants and principal auth 2026-08-04 23:04:24 -05:00
Makefile sec: a hand-rolled secrets manager, written entirely in Lisette 2026-08-04 01:42:31 -05:00
README.md remove local ref 2026-08-05 03:27:49 -05:00

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 like localhost is 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 1 gauge in /metrics, dev_mode: true in /healthz, and a dev-mode: prefix on every audit principal and updated_by.

Two things it deliberately does not do:

  • It does not relax authorization. The synthetic principal is an admin, but write_secret still 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:

  1. Declared (path, field) with no row → insert a TODO:replace_me stub with is_stub = 1.
  2. Declared (path, field) with a row → touch nothing. Values are never overwritten by the reconciler.
  3. Row under a managedPrefix that is not declared → copy to secret_versions as a tombstone, then delete.
  4. 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 / start directly, debounced to one restart per unit per poll.
  • is_stub is honoured: the agent refuses to overwrite a good cached value with a TODO:replace_me placeholder.
  • 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.