Fredrik Johansson 001623c8e2 Resolve open design questions: write-scoped grants, rollback, purge
Per-question resolution, per best practice rather than deferral:

- Push authorization: closed the least-privilege gap where read implied
  write. vault_grants gained can_write (default true, so nothing
  existing changes); enforced on push and grant (granting others is
  itself a mutation of vault membership, so it needs write too, not
  just read). 'keep grant --read-only' creates a read-only grant.

- Version history: not full history (conflates "undo a typo'd push"
  with "this leaked, stop retaining it" into one mechanism). Retains
  exactly one previous version as a rollback safety net
  (vaults.prev_ciphertext/prev_nonce + a vault_grants_previous mirror
  table so recipients can unwrap it), plus 'keep push --purge' for
  compromise-driven rotations that explicitly skips retention and
  wipes any existing previous version too.

- Per-secret-key granularity: resolved by NOT building it — documented
  the escape hatch (split into more vaults) instead of adding partial-
  decrypt complexity for a problem the existing primitive solves.

One more real bug caught during verification: the CLI's --previous flag
initially signed a path including its query string, but the server
verifies against req.originalUrl with the query stripped — a mismatch
that would have made every --previous request fail signature
verification. Fixed by splitting the signed path from the request URL
in signedFetch, signing only the former.

Verified end-to-end with three independent identities: read-only grant
correctly blocked from push and from granting others, write access and
read-only status both preserved correctly across a rotation, previous-
version pull working for a routine push and correctly unavailable to
every recipient after a purge push. Also re-verified against a fresh
Docker build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:50:08 +02:00

keep

A self-hosted, end-to-end encrypted secrets store for deploy scripts. keep pull myapp/production gets you a .env-shaped bundle of secrets, decrypted locally — the server never sees plaintext values, and never sees the key that would let it.

Why

Across a small family of self-hosted projects, every repo tends to have its own .env/.env.example pair, and every one of those .env files exists today by being hand-copied onto whatever machine needs it. That's a real, already-felt annoyance, not 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 manually reconstructing a .env from memory, a password manager note, or asking "hey, what's the value for X."

keep is a small always-on service that holds encrypted secret bundles and lets authorized machines/people pull them, decrypted locally, without ever exposing plaintext to the server in between.

What it isn't

  • Not Vault or Doppler. Those are the established, heavier answers — full policy engines, dynamic secrets, enterprise everything. keep is the "already-solved problem, but self-hosted and scoped to what this actually needs" answer, closer in spirit to age/sops (multi-recipient encrypted files) than to a full secrets-management platform.
  • Not a static encrypted file committed to git, which is what age/sops typically produce. keep exists specifically for the two things a committed encrypted file can't give you without re-running a tool and re-committing: live rotation (push once, everyone with access can pull the update) and an access log (who/what actually fetched a secret bundle, and when — a git history shows content changes, not access).
  • Not an ephemeral file-drop tool. Something built for single-retrieval, self-destructing file sharing is the opposite of what a secret needs to be — a deploy script needs to fetch the same bundle on every deploy, not once and then have it vanish.

Core model

Adapts the pattern age/sops/PGP already use for multi-recipient encrypted files — a symmetric key encrypts the actual payload, and that symmetric key is separately "wrapped" (encrypted) once per authorized recipient's public key — into a live service instead of a static file:

  1. Each client (a developer's machine, a deploy script running on a VPS) has its own Ed25519/X25519 keypair.
  2. A vault (myapp/production, myapp/staging, whatever key) holds one encrypted blob — the actual secret bundle, encrypted under a random symmetric key.
  3. That symmetric key is wrapped separately for every recipient granted access to that vault. The server stores N small wrapped-key blobs, one per recipient, alongside the one encrypted payload.
  4. keep pull <vault> fetches the payload plus this identity's wrapped key, unwraps locally with the private key, decrypts, and prints (or writes) the .env-shaped result.
  5. Revoking a recipient's access is deleting their one wrapped-key row — the payload and every other recipient's access are untouched.
  6. Rotating a secret is keep push <vault> again — a new random symmetric key, re-wrapped for every currently-granted recipient. Cheap, since the recipient list rarely changes.

Full design — data model, the atomic-grant/revoke details, and what this explicitly does and doesn't protect against — is in IMPLEMENTATION.md. Ideas for wiring this into existing deploy scripts are in INTEGRATION.md.

Quickstart

# 1. Run the server (see "Docker" below for a real deploy)
cp .env.example .env
npm install
npm run dev:server

# 2. Each machine/person that needs access generates its own identity
npm run cli -- identity init
#   → prints a public key

# 3. An admin registers that public key as a recipient
KEEP_ADMIN_PASSWORD=... npm run cli -- recipient add --label "my-laptop" --pubkey <hex>
#   → prints a recipient id

# 4. The machine that generated the identity records its assigned id
npm run cli -- identity set-id <recipient-id>

# 5. Push a vault — the pusher is automatically its first recipient
npm run cli -- push myapp/production --file .env.production

# 6. Anyone else with a grant can pull it, decrypted, ready to use
npm run cli -- pull myapp/production > .env

Granting, revoking, rotating

# An existing recipient of a vault can grant a NEW recipient access,
# without needing admin rights or re-encrypting the payload. Read access
# by default — add --read-only for a recipient that should only ever
# pull, never push (e.g. an automated deploy identity):
keep grant myapp/production <new-recipient-id>
keep grant myapp/production <deploy-recipient-id> --read-only

# Revoking is an admin operation — pure metadata deletion, immediate:
KEEP_ADMIN_PASSWORD=... keep revoke myapp/production <recipient-id>

# Revoke only stops FUTURE pulls. If this was a compromise response,
# rotate AND purge — --purge skips retaining the outgoing version as a
# rollback, since the whole point of rotating for a leak is that the old
# value stops being retrievable by anyone:
keep push myapp/production --file .env.production --purge

# A routine push (no compromise involved) keeps the outgoing version as
# a one-step rollback safety net:
keep push myapp/production --file .env.production
keep pull myapp/production --previous   # "oops, undo that last push"

# See who's touched a vault and when:
keep log myapp/production

Pushing to an existing vault requires a write grant — a read-only recipient can pull but can't push or grant others access. A brand-new vault's first push is always read-write for the pusher.

Docker

docker compose up -d

Uses the image from IMAGE in .env. For a local build:

docker build -t keep:local .
IMAGE=keep:local docker compose up -d

CI/CD

Gitea Actions workflow at .gitea/workflows/docker.yml. Builds and pushes to the Gitea container registry on every push to main. Requires a TKNTKN secret (PAT with packages:write) in the repo settings.

Environment variables

Variable Default Description
PORT 3050 Port to listen on
DATA_DIR ./data Where the SQLite DB (vaults, recipients, grants) lives
ADMIN_PASSWORD Gates recipient add/remove and revoke. Unset disables /api/admin/* (503) — push/pull/grant for already-registered recipients keep working
REQUEST_MAX_SKEW_SECONDS 300 Replay-protection window for signed CLI requests

CLI-side: KEEP_SERVER_URL (default http://localhost:3050), KEEP_ADMIN_PASSWORD (for admin commands: recipient add/list/remove, revoke).

Status

Implemented: identity, push/pull/grant/revoke/log, read/write grant scoping, one-step rollback with an explicit purge mode for compromise-driven rotations, admin recipient management, Docker deploy. Verified end-to-end — two and three independent identities, read-only grants correctly blocked from push/grant, grants preserved across rotation, previous-version pull working and correctly wiped by --purge for every recipient, malformed-auth rejection — against both a local server and the built Docker image. See IMPLEMENTATION.md for the full design and its resolved decisions (per-secret-key granularity deliberately not built — use multiple vaults instead).

License

MIT. See LICENSE.

Description
A self-hosted, end-to-end encrypted secrets store for deploy scripts.
Readme MIT 468 KiB
Languages
TypeScript 81.8%
Shell 8.8%
JavaScript 8.1%
Dockerfile 1.3%