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>
2026-07-12 19:38:24 +02:00
|
|
|
# Incorporating keep into an existing deploy setup
|
|
|
|
|
|
|
|
|
|
Concrete, by deploy *pattern* rather than by naming specific projects —
|
|
|
|
|
the shape of the fit depends on how a project currently gets its config
|
|
|
|
|
onto a target machine, not on what the project is.
|
|
|
|
|
|
|
|
|
|
## The general shape
|
|
|
|
|
|
|
|
|
|
Wherever a deploy script or a manually-maintained `.env` exists today, the
|
|
|
|
|
change is the same: replace "assume `.env` is already sitting there,
|
|
|
|
|
hand-copied at some point" with a `keep pull` at the top of the script.
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
# before — a deploy script assumes .env already exists on this machine
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
source .env # or docker-compose reads it directly
|
|
|
|
|
|
|
|
|
|
# after
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
keep pull <project>/<env> --out .env
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The one-time setup cost per machine: `keep identity init` once, send the
|
|
|
|
|
printed public key to whoever runs `keep recipient add`, then
|
|
|
|
|
`keep identity set-id <id>` with what comes back. After that, every future
|
|
|
|
|
deploy from that machine just works — no more "wait, what's the DB
|
|
|
|
|
password again" when setting up a new box.
|
|
|
|
|
|
2026-07-12 19:54:45 +02:00
|
|
|
A deploy identity should almost always be granted **read-only** access
|
|
|
|
|
(`keep grant <vault> <deploy-recipient-id> --read-only`) — it only ever
|
|
|
|
|
needs to `pull`, and a read-only grant means a compromised deploy box
|
|
|
|
|
can't also rotate the vault or add itself more recipients.
|
|
|
|
|
|
2026-07-12 20:16:29 +02:00
|
|
|
## Pattern: several docker-compose projects on one or more VPSes
|
|
|
|
|
|
|
|
|
|
The common shape once more than one or two projects are on `keep`:
|
|
|
|
|
each project lives in its own directory on the VPS
|
|
|
|
|
(`/opt/docker/<project>/docker-compose.yml`), and "deploy" means pulling
|
|
|
|
|
that project's env and running `docker compose up -d` in its directory.
|
|
|
|
|
`scripts/deploy.sh` in this repo is a small generic wrapper for exactly
|
|
|
|
|
that:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
./deploy.sh myapp # pulls myapp/production, runs compose in /opt/docker/myapp
|
|
|
|
|
./deploy.sh myapp staging # pulls myapp/staging instead
|
|
|
|
|
DEPLOY_ROOT=/srv/apps ./deploy.sh myapp # different project root
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
One-time setup per VPS: `keep identity init` on that machine, register
|
|
|
|
|
its public key with `keep recipient add`, `keep identity set-id`, then
|
|
|
|
|
grant it **read-only** access to each project vault it deploys
|
|
|
|
|
(`keep grant <project>/production <vps-recipient-id> --read-only`). After
|
|
|
|
|
that, `./deploy.sh <project>` is the whole deploy step — no `.env`
|
|
|
|
|
hand-copied onto the box first.
|
|
|
|
|
|
|
|
|
|
For "which machine can deploy which project," `keep overview` (admin)
|
|
|
|
|
prints every vault and every recipient's access in one screen — the
|
|
|
|
|
thing to reach for instead of checking each vault's grants one at a
|
|
|
|
|
time.
|
|
|
|
|
|
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>
2026-07-12 19:38:24 +02:00
|
|
|
## Pattern: docker-compose reading a local `.env`
|
|
|
|
|
|
|
|
|
|
A common shape — `docker compose up -d` reads `environment:` values from
|
|
|
|
|
a `.env` file sitting next to `docker-compose.yml` on the deploy machine,
|
|
|
|
|
hand-maintained and containing real secrets.
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
keep pull myapp/production --out .env
|
|
|
|
|
docker compose up -d
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
No code changes needed in the target project — `keep` only changes *how
|
|
|
|
|
the `.env` file gets there*, not what reads it. This is the easiest
|
|
|
|
|
category to retrofit.
|
|
|
|
|
|
|
|
|
|
## Pattern: cross-compile + scp + SSH-restart deploy scripts
|
|
|
|
|
|
|
|
|
|
A shape where a deploy script builds a binary locally, ships it to a VPS,
|
|
|
|
|
and restarts a service over SSH — reading a local `.env` next to the
|
|
|
|
|
script for things like shared secrets that shouldn't be hardcoded or
|
|
|
|
|
re-typed on every invocation.
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
keep pull myapp/production --out .env
|
|
|
|
|
set -a && source .env && set +a
|
|
|
|
|
# ...rest of the deploy script unchanged
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Same retrofit as the docker-compose case, just applied before whatever
|
|
|
|
|
build/ship step already exists.
|
|
|
|
|
|
|
|
|
|
## Pattern: a plaintext config file served to a browser
|
|
|
|
|
|
|
|
|
|
Not every `config.js`/similar file that *looks* like environment config
|
|
|
|
|
actually holds secrets — some of it is public-by-design (an API base URL,
|
|
|
|
|
a feature flag) that's explicitly meant to be visible to anyone loading
|
|
|
|
|
the page. `keep` solves a confidentiality problem; a file with nothing
|
|
|
|
|
confidential in it doesn't have one. Worth checking this distinction
|
|
|
|
|
before wiring `keep` into anything — retrofitting a non-secret config
|
|
|
|
|
file would be solving a problem that file doesn't have.
|
|
|
|
|
|
|
|
|
|
## A vault server's own configuration
|
|
|
|
|
|
|
|
|
|
Whatever runs `keep` itself needs its own `ADMIN_PASSWORD` to be a real,
|
|
|
|
|
manually-set secret on its own host — it can't bootstrap its own trust
|
|
|
|
|
root by pulling itself from itself. This is the same "root of trust
|
|
|
|
|
starts somewhere unmanaged" fact every secrets system has (Vault's own
|
|
|
|
|
unseal keys aren't stored in Vault either). Not a gap, just worth being
|
|
|
|
|
explicit about: `keep`'s own deploy stays on a plain `.env`/
|
|
|
|
|
`docker-compose.yml` environment variables.
|
|
|
|
|
|
|
|
|
|
## New projects going forward
|
|
|
|
|
|
|
|
|
|
The actual best use case isn't retrofitting existing deploys — it's never
|
|
|
|
|
having the "hand-reconstruct a `.env` from memory" moment for a *new*
|
|
|
|
|
deploy target in the first place. For whatever gets built next: register
|
|
|
|
|
its production host as a `keep` recipient as part of first setup, write
|
|
|
|
|
the deploy script to `keep pull` from day one, and the annoyance this was
|
|
|
|
|
built to fix never has a chance to happen.
|