Case study: keep
Context
A small family of self-hosted projects, each with its own .env/.env.example pair. In practice every one of those .env files exists by being hand-copied onto whatever machine needs it — a real, already-felt annoyance rather than a hypothetical one. Rotating a credential means finding and updating every machine that has a copy, by hand. There’s no record of which machine has which secret, or when it was last updated. Onboarding a new deploy target means reconstructing a .env from memory, a password manager note, or asking someone what the value for X is.
Problem
Build a secrets store where the server operator is assumed hostile-but-honest — they might try to read stored secrets, and the design has to make that cryptographically impossible, not just policy-forbidden — while still being a live service: push once, every authorized machine can pull the update, and there’s a real log of who fetched what and when. Neither half of that is satisfied by the two obvious alternatives. A full secrets platform (Vault, Doppler) is more infrastructure than “a person running their own homelab” needs. A statically committed age/sops-encrypted file gives you the encryption but not live rotation or an access log — every update means re-running a tool and re-committing, and a git history shows content changes, not who actually pulled a secret and when.
Approach
1. Multi-recipient wrapping, as a service instead of a file
The core scheme is the one age/sops/PGP already use for multi-recipient encrypted files, just made live. Each vault (myapp/production) holds one payload encrypted under a fresh random symmetric key (crypto_secretbox, XSalsa20-Poly1305). That symmetric key is separately sealed — crypto_box_seal, anonymous sealing — once per recipient granted access, so the server stores N small wrapped-key blobs alongside the one ciphertext. keep pull fetches the payload plus this identity’s own wrapped key, unwraps locally, decrypts. Granting a new recipient doesn’t touch the payload. Revoking is deleting one wrapped-key row.
Identity is a single Ed25519 keypair per client, reused for two purposes via the standard sign-to-curve25519 conversion: signing (proving who’s pushing/pulling, for the access log) and encryption (deriving the X25519 keypair used for sealing). One identity type instead of two, since a client only ever needs to prove and receive as the same person.
2. A vault with no grants is unreadable by anyone — including its creator
Zero rows in vault_grants for a vault means it’s unreadable, full stop — even by whoever just pushed it, unless they granted themselves access as part of that push. This is deliberate: creating a vault and granting access to it are kept as separate steps so a vault can never accidentally end up “open” to nobody, or silently inherit a stale recipient list from a template.
3. Read no longer implies write
The first-pass design let any recipient with a grant push. A deploy identity — a VPS that only ever needs to pull — had the power to overwrite the vault it was reading from. A compromised read-only consumer could push garbage values or exclude other recipients from the next rotation. Closed by adding a can_write flag to each grant (1 by default, so nothing existing changed behavior) and enforcing it in the push handler. keep grant <vault> <id> --read-only creates a grant that can pull but can never push or grant others access — the shape a deploy box’s identity should almost always have.
4. One rollback slot, plus an explicit purge path
Two different needs get conflated if you build one version-history feature: “I pushed a typo’d value, let me undo that” wants a safety net, and “this credential leaked” wants the old value to stop existing anywhere retrievable — a kept version directly undermines the second one. Rather than build full N-version history (the Vault/AWS Secrets Manager answer, and more complexity than either real use case needs), keep retains exactly the immediately previous payload (keep pull --previous) as a rollback net for routine pushes, and gives compromise-driven rotations an explicit keep push --purge that skips retention entirely and actively clears whatever previous version already existed. A routine rotation and an incident response are different operations with different guarantees, so they’re different flags rather than one path trying to serve both.
5. A multi-statement push shouldn’t be left half-done
push reads the current vault_grants and writes new ciphertext plus every recipient’s re-wrapped key. Wrapping that read-then-write sequence in a single SQLite transaction isn’t guarding against a race with a concurrent grant or revoke — Node never actually yields mid-handler here (no real await between the read and the write, and every DB call is better-sqlite3’s synchronous API), so two requests can’t interleave at the statement level regardless. What the transaction actually buys is crash-atomicity: if the process dies between writing the new ciphertext and finishing the wrapped-key rows, a restart shouldn’t find the vault half-rotated — new payload, stale keys.
6. Admin authorization, scoped down on purpose
Adding/removing recipients and revoking access is a materially different trust level than pushing/pulling a vault you already have a grant for. Three shapes were on the table: a bootstrap-admin identity (cheapest, doesn’t scale past “it’s just me”), a per-vault can_admin grant (most correct, most to build), or a separate shared ADMIN_PASSWORD gating a small admin surface. Went with the password — disabled entirely (503) rather than boot-failing when unset — because a global admin password is an acceptable, well-understood trust model at the actual scale this runs at: one person or a small team running their own infrastructure, not a multi-tenant platform.
7. What actually broke during end-to-end verification
Two real bugs, not just written-up design: Express’s req.path inside a sub-router is relative to that router’s mount point, not the full API path — a client signing the full request path would silently mismatch. Fixed by verifying against req.originalUrl instead, which stays correct regardless of router nesting. Separately, the CLI’s --previous flag initially signed a path that included its own query string, while the server verified against the path with the query string stripped — every --previous request would have failed signature verification. Fixed by splitting the signed path from the request URL in signedFetch.
Both were caught before shipping, by actually running the thing: the verification pass covered two and three independent identities, read-only grants correctly blocked from push/grant, write and read-only status preserved across a rotation, --previous returning the right prior version, --purge confirmed to make the prior version unavailable for every recipient, and malformed-auth correctly rejected — against both a local server and the built Docker image.
8. An overview, without a web UI
Running several small self-hosted projects, each with its own vault, surfaces a real question the per-vault CLI surface doesn’t answer well: which machines can see which vaults, across all of them, right now? Each existing read (GET /vaults/:key/recipients) is scoped to one vault at a time and gated on the caller already holding a grant on it — correct for a client re-wrapping its own push, wrong for “let me just look.”
Rather than stand up a web UI — a new session/auth surface for what’s fundamentally a read — keep overview is one admin-gated endpoint (GET /api/admin/overview) that joins every vault against its grants and every recipient’s label, and a CLI command that prints it as two flat lists: every registered recipient, then every vault with its grantees marked rw/r-. No new trust boundary — it reuses the existing ADMIN_PASSWORD gate — and no new dependency, just two small queries (listVaults, reusing listGrantsForVault) composed server-side instead of walked one vault at a time from the client.
9. Closing the loop on deploy scripts
IMPLEMENTATION.md’s deploy-integration sketch was always “keep pull at the top of an existing script.” For a homelab running several docker-compose projects across one or more VPSes, that pattern gets a small generic wrapper — scripts/deploy.sh <project> [env] — instead of rewriting per project: pull <project>/<env> into that project’s directory as .env, then docker compose up -d there. Each VPS gets one keep identity, granted read-only on the vaults it deploys, so a compromised deploy box can pull but never rotate or re-grant. Combined with keep overview, provisioning a new VPS is: register its identity, grant it read-only on the vaults it needs, confirm with overview, done — no step where a secret is hand-typed onto the box.
What’s missing
- Per-vault admin grants — the
ADMIN_PASSWORDmodel is a global trust boundary; scoping admin authority per-vault (can_adminon a grant) would fit a team past “it’s just me” better, deliberately deferred as not the v1 scale. - Per-secret-key granularity — resolved by not building it: different recipients needing different subsets of one vault’s secrets is a job for more vaults (
myapp/db,myapp/api), not partial-access ACLs inside one blob. Documented as the escape hatch rather than left as a dangling TODO. - No web UI, by design — API + CLI only;
keep overviewcloses the one real gap (a cross-vault view) without adding a new auth surface.
Stack
| Layer | Tech |
|---|---|
| Server | Node.js, Express |
| Crypto | libsodium-wrappers (crypto_secretbox, crypto_box_seal, Ed25519/X25519) |
| Persistence | SQLite (better-sqlite3) — vaults, recipients, grants, access log |
| CLI | TypeScript, tsx |
| Deploy | Docker, Gitea Actions CI |