Files
keep/README.md
Fredrik Johansson 8dcb675ad2
All checks were successful
Docker / build-and-push (push) Successful in 1m51s
Save a redacted .env template alongside pushed secrets
keep push now saves a comment/structure-preserving, value-redacted copy
of the source .env next to the encrypted secrets, so context like "why
this exists" or "get this from X" survives ingest instead of being
silently dropped. keep pull --template retrieves it. Old vaults pushed
before this existed keep working (flat payload, no template) and fail
with a clear error rather than crashing if --template is requested.
2026-07-15 20:41:45 +02:00

244 lines
10 KiB
Markdown

# 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.
![keep is a CLI, not a web app — no server UI to screenshot. This is a full round trip: register an identity, push a vault, grant a second identity read-only access, and watch it get correctly blocked from writing.](./docs/cli-example.png)
`keep` has no web UI by design — it's API + CLI only, so there's nothing
server-side to show beyond the terminal above.
## 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. By default the outgoing
version is kept as a one-step rollback safety net (`keep pull
--previous`); `keep push --purge` skips that for compromise-driven
rotations where the old value should stop being retrievable, full stop.
7. Grants distinguish read from write — a recipient can be given
read-only access (`keep grant --read-only`), which can pull a vault
but can't push to it or grant others access.
Full design — data model, the atomic-grant/revoke details, and what this
explicitly does and doesn't protect against — is in
[IMPLEMENTATION.md](./IMPLEMENTATION.md). Ideas for wiring this into
existing deploy scripts are in [INTEGRATION.md](./INTEGRATION.md).
## Quickstart
```bash
# 1. Run the server (see "Docker" below for a real deploy)
cp .env.example .env
npm install
npm run dev:server
# 2. Build the CLI and link it onto PATH as a real `keep` command --
# no npm run cli --, no cd-ing into this repo from wherever you
# actually need it (a deploy directory on some VPS, for instance).
# Symlinked, not copied: a future `git pull && npm run build` here
# is the entire upgrade story, no need to re-run this.
./scripts/install-cli.sh
# 3. Each machine/person that needs access generates its own identity
keep identity init
# → prints a public key
# 4. An admin registers that public key as a recipient
KEEP_ADMIN_PASSWORD=... keep recipient add --label "my-laptop" --pubkey <hex>
# → prints a recipient id
# 5. The machine that generated the identity records its assigned id
keep identity set-id <recipient-id>
# 6. Push a vault — the pusher is automatically its first recipient
keep push myapp/production --file .env.production
# 7. Anyone else with a grant can pull it, decrypted, ready to use
keep pull myapp/production > .env
```
## Templates
`keep push` also saves a redacted copy of the source `.env` alongside
the secrets — same keys, comments, and blank lines, values stripped.
Comments explaining *where a value comes from* or *why it exists* are
exactly the kind of context a bare key/value pair throws away, so this
keeps it without keeping the secret itself around unencrypted anywhere.
```bash
keep pull myapp/production --template > .env.example
```
Vaults pushed before this existed just don't have a saved template —
`keep pull --template` on one of those fails with a clear error instead
of silently returning nothing. No migration needed; push again and the
template gets backfilled from whatever `.env` you push next.
## Granting, revoking, rotating
```bash
# 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.
## Announcing deploys
`keep` can also relay a small "there's a new image" signal alongside a
vault, so CI never needs standing SSH access to a deploy target — CI
announces a tag, and whatever already redeploys that box locally polls
for it instead of being reached into from outside:
```bash
# CI, after a successful build/push — any grant is enough, read-only included:
keep announce myapp/production --tag <short-sha>
# The deploy target polls for a change and prints it:
keep watch myapp/production --interval 60
```
An announced tag isn't a secret — it lives outside the encrypted vault
payload, in its own small table, and is visible to the server (same as
vault names and access timestamps already are). `keep` never pulls or
runs the announced image itself; it only relays the string. See
[IMPLEMENTATION.md](./IMPLEMENTATION.md) for how this fits into a bulk
deploy script with a no-`keep` fallback for projects not yet bootstrapped
onto it.
## Docker
```bash
docker compose up -d
```
Uses the image from `IMAGE` in `.env`. For a local build:
```bash
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, an admin
cross-vault `overview`, deploy-signal `announce`/`watch`, a
`scripts/deploy.sh` wrapper, and 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, a read-only identity successfully announcing while
still being logged, `watch` picking up an announced tag and exiting
nonzero for an unknown/ungranted vault, malformed-auth rejection —
against both a local server and the built Docker image. See
[IMPLEMENTATION.md](./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](./LICENSE).