# 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 ` 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 ` 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 # → prints a recipient id # 4. The machine that generated the identity records its assigned id npm run cli -- identity set-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 keep grant myapp/production --read-only # Revoking is an admin operation — pure metadata deletion, immediate: KEEP_ADMIN_PASSWORD=... keep revoke myapp/production # 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).