IMPLEMENTATION.md hadn't been updated after the second implementation pass — the data model SQL was missing prev_ciphertext/prev_nonce, can_write, and the entire vault_grants_previous table; the Rotation section described the old "overwritten, not versioned" behavior (the opposite of what got built); the crypto scheme section still said "read implies write" while the Resolved design decisions section below it said the opposite; the CLI surface listing was missing --purge/--previous/--read-only; and "What was verified" only covered the first verification pass, not the read-only/rollback/purge one. README's Core model was missing any mention of the retention/purge behavior or read/write grant scoping. INTEGRATION.md now recommends --read-only for deploy identities specifically, now that the capability exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
188 lines
7.9 KiB
Markdown
188 lines
7.9 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.
|
|
|
|
## 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. 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
|
|
|
|
```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.
|
|
|
|
## 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, 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](./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).
|