Case studyPersonal project2026

top-secrets

A CLI secrets manager that hardens your .env instead of replacing it. sops and age underneath, a TOTP gate on top, and a leak-detection guard that I had to switch off when it started auditing itself.

top-secrets CLI help output and guard service status in a terminal

On June 5th, a scheduled script on my Raspberry Pi crashed and printed my Gemini API key into an error log. Nothing was compromised, but the failure mode was obvious: plaintext .env files scattered across a homelab, read by cron jobs nobody watches.

The usual answer is to adopt a real secrets manager. I wanted something narrower: keep the .env workflow developers already have, and put a gate in front of reading it.

Rule one: do not invent crypto

The first decision was a constraint, not a feature. The MVP wraps an already-evaluated primitive, it does not implement encryption.

I compared three options: sops with age, Infisical, and OpenBao. sops and age won because they need zero running services, encrypt per file, and are the simplest thing that survives on a Raspberry Pi. The differentiator, a second factor before a secret is read, is a thin layer on top rather than a new secrets store.

topsecrets run [--unattended] <file> -- <cmd>
  → confirm with a TOTP code
  → sops -d on <file>
  → inject each key as an env var, scoped to the subprocess
  → never print the value

The bypass had to be loud

topsecrets run is how a real service consumes its secrets, and a cron job has no terminal to type a TOTP code into. Asking for one would hang the process forever.

The tempting fix is to check for a tty and skip the gate when there is none. I rejected that, because it makes "having no terminal" the master key to your second factor. Any unattended process, including a compromised one, skips the gate for free and leaves no trace in the code that a bypass happened there.

Instead the bypass is an explicit flag: --unattended. Same outcome for the cron job, but now every bypass is greppable across every consumer, instead of being invisible behavior inside the CLI.

The general rule I took from it: when a tool serves both humans and unattended processes and there is a security gate between them, prefer an explicit flag over sniffing the environment. Auto-detection turns an ambient signal into a door.

Membership and the gate are different layers

I gated add-machine with TOTP, like every other command. That made it impossible for a new machine to enroll itself: producing a valid TOTP code requires decrypting the shared seed, which is exactly the access the new machine does not have yet.

That is not a bug, it is the only correct shape. Someone who is already in the vault has to vouch for the new machine from their own machine. Only then can the new one read the seed, which needs no TOTP gate because the gate there is purely cryptographic: if its key is not a recipient, sops refuses on its own.

The guard that audited itself

The last piece was a guard: an auditd rule watching every execve, piping each command through gitleaks, and suggesting a rotation when it spotted a secret in a command line.

It worked. It also ran the laptop at 80 to 83 °C for thirteen hours and burned 6 hours 46 minutes of CPU.

checkpoint behind:                    26 minutes
pending PROCTITLE events:             32,636
distinct gitleaks processes in 15s:   11

The cause is the thing I should have seen when I wrote it. The auditd rule logs every execve, including the ones the watcher itself produces. ausearch, grep, sed, and every gitleaks invocation re-enter the queue they came from. The watcher generated events as fast as it consumed them, so the backlog never converged.

I confirmed the temperature link with a controlled three minute test rather than assuming it:

guard running:    80 °C, 38.5% CPU
guard stopped:    48-62 °C, 8.6-16.3% CPU, 0 gitleaks
guard restarted:  76 °C after 3 seconds

So I disabled the service. It stays off until the processing chain is decoupled from its own event source, or the commands are handled in batches with a bounded queue, and until it survives several hours without the checkpoint growing.

Shipping the guard was the easy part. Measuring it honestly and turning it off was the part worth writing down.

Status

The CLI is in use: get, set, rotate, run, and multi-machine enrollment all work on top of sops and age. The guard is written and currently disabled. The next step is running it against a real .env in a production project to find out whether the TOTP gate is a genuine safety net or just friction.

Stack

Bashsopsageoathtoolauditdgitleakssystemd