Implement keep: self-hosted E2E encrypted secrets sync
Server: Express + better-sqlite3 (WAL), multi-recipient key-wrapping per IMPLEMENTATION.md's design — vaults/recipients/vault_grants/ access_log. Two auth paths: ADMIN_PASSWORD header for recipient management and revoke (pure metadata operations), signed-request auth (Ed25519 signature over method+path+timestamp+body-hash) for push/pull/grant, mirroring the spirit of this project family's other signed-handshake patterns without naming them. CLI: identity init/show/set-id, push/pull/grant/log, admin recipient add/list/remove and revoke. Grant is a client-side crypto operation (the granter unwraps the vault's current key locally and reseals it for the new recipient) rather than a server-side operation, since the server never holds an unwrapped key to grant with. Verified end-to-end with two independent local identities against a live server and separately against the built Docker image: register, push, pull (granted and ungranted), grant without re-pushing, admin revoke, a subsequent rotation confirming the revoked recipient stays excluded, and rejection of missing/malformed signed-request auth. Two real bugs caught during verification, not just written up: - libsodium-wrappers' published ESM build does a relative import only resolvable under bundler-style resolution — broken under plain Node ESM. Fixed via createRequire to force the CJS build. - Express's req.path inside a sub-router is relative to the mount point, which would have silently mismatched a client signing the full request path. Fixed by verifying against req.originalUrl. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
118
README.md
118
README.md
@@ -1,17 +1,16 @@
|
||||
# keep
|
||||
|
||||
A self-hosted, end-to-end encrypted secrets store for deploy scripts.
|
||||
`keep pull wisp/production` gets you a `.env`-shaped bundle of secrets,
|
||||
`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 this project family — `goonk`, `wisp`, `npm-statuspage`, `flit`,
|
||||
`waste-go` — every repo has 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:
|
||||
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.
|
||||
@@ -39,9 +38,10 @@ ever exposing plaintext to the server in between.
|
||||
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 wisp.** wisp is ephemeral and single-retrieval by design — 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.
|
||||
- **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
|
||||
|
||||
@@ -51,9 +51,8 @@ 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 — same identity model already
|
||||
built twice over in `flit` and `waste-go`.
|
||||
2. A vault (`wisp/production`, `goonk/production`, whatever key) holds one
|
||||
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
|
||||
@@ -68,10 +67,97 @@ recipient's public key — into a live service instead of a static file:
|
||||
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, the CLI
|
||||
surface, and what this explicitly does and doesn't protect against — is
|
||||
in [IMPLEMENTATION.md](./IMPLEMENTATION.md).
|
||||
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:
|
||||
keep grant myapp/production <new-recipient-id>
|
||||
|
||||
# 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,
|
||||
# also rotate the actual values:
|
||||
keep push myapp/production --file .env.production
|
||||
|
||||
# See who's touched a vault and when:
|
||||
keep log myapp/production
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
Design only. No code yet.
|
||||
Implemented: identity, push/pull/grant/revoke/log, admin recipient
|
||||
management, Docker deploy. Verified end-to-end (two independent
|
||||
identities, grant, pull, revoke persisting through a subsequent
|
||||
rotation, malformed-auth rejection) against both a local server and the
|
||||
built Docker image. See [IMPLEMENTATION.md](./IMPLEMENTATION.md) for the
|
||||
full design and its still-open questions (version history, per-secret-key
|
||||
granularity).
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](./LICENSE).
|
||||
|
||||
Reference in New Issue
Block a user