All checks were successful
Docker / build-and-push (push) Successful in 1m51s
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.
244 lines
10 KiB
Markdown
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` 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).
|