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:
Fredrik Johansson
2026-07-12 19:38:24 +02:00
parent 2c34562c7f
commit 67000b66ee
27 changed files with 3343 additions and 106 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
data
docker-compose.yml
Dockerfile
.env
.git
README.md
IMPLEMENTATION.md
INTEGRATION.md

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
PORT=3050
DATA_DIR=./data
# Gates recipient add/remove and revoke. Unset disables /api/admin/* (503)
# but push/pull/grant keep working for already-registered recipients.
ADMIN_PASSWORD=change-me
# Replay-protection window (seconds) for signed CLI requests.
REQUEST_MAX_SKEW_SECONDS=300

View File

@@ -0,0 +1,53 @@
name: Docker
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Derive image name and tags
id: meta
shell: bash
run: |
set -euo pipefail
SERVER_URL="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
if [[ -z "${SERVER_URL}" || -z "${REPO}" || -z "${SHA}" ]]; then
echo "Missing SERVER_URL/REPO/SHA env vars." >&2; exit 1
fi
HOST=$(echo "${SERVER_URL}" | sed 's|https://||;s|http://||')
IMAGE=$(echo "${HOST}/${REPO}" | tr '[:upper:]' '[:lower:]')
SHORT_SHA=$(echo "${SHA}" | cut -c1-7)
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ steps.meta.outputs.host }}
username: ${{ github.actor }}
# Create a Gitea PAT with packages:write scope and add it as a
# repository secret named TKNTKN (Settings → Secrets)
password: ${{ secrets.TKNTKN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
tags: |
${{ steps.meta.outputs.image }}:latest
${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.short_sha }}

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
data/

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:22-alpine AS build
WORKDIR /app
# better-sqlite3 has a native addon with no prebuilt binary for this
# platform/Node combo yet — build it from source.
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
# Same native-addon rebuild needed for the production-only install below;
# the toolchain is purged again afterward to keep the runtime image lean.
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm install --omit=dev && apk del python3 make g++
COPY --from=build /app/dist ./dist
# DATA_DIR (SQLite DB) is a mutable runtime volume — not baked into the
# image, so redeploys don't clobber recipients/grants/vaults.
ENV DATA_DIR=/app/data
EXPOSE 3050
CMD ["node", "dist/server/index.js"]

View File

@@ -1,13 +1,13 @@
# keep — implementation notes # keep — implementation notes
Design-stage document. Nothing here is built yet; this is the spec to Design-stage document, now implemented — kept as the record of *why* the
build against, not a description of existing code. design looks the way it does, not just a description of the code.
## Threat model ## Threat model
- The server operator can be assumed hostile-but-honest, same posture as - The server operator can be assumed hostile-but-honest: they might try
`wisp`: they might try to read stored secrets, but the design should to read stored secrets, but the design should make that cryptographically
make that cryptographically impossible, not just policy-forbidden. impossible, not just policy-forbidden.
- The server **can** see: which vaults exist, which recipients exist, - The server **can** see: which vaults exist, which recipients exist,
which recipients are granted access to which vaults, payload size, and which recipients are granted access to which vaults, payload size, and
access timestamps (who pulled/pushed what, when). It **cannot** see: access timestamps (who pulled/pushed what, when). It **cannot** see:
@@ -25,50 +25,43 @@ build against, not a description of existing code.
way (age, sops, Vault, all of them) — worth stating explicitly because way (age, sops, Vault, all of them) — worth stating explicitly because
it's the single most common misunderstanding of what "revoke" buys you. it's the single most common misunderstanding of what "revoke" buys you.
- **Not defended against**: a recipient with legitimate access - **Not defended against**: a recipient with legitimate access
deliberately exfiltrating secrets they're authorized to read. Same deliberately exfiltrating secrets they're authorized to read. Access
out-of-scope boundary wisp draws around a recipient re-sharing a control stops unauthorized reads, not authorized-then-malicious ones —
decrypted file — access control stops unauthorized reads, not the same boundary any system like this draws.
authorized-then-malicious ones.
## Crypto scheme ## Crypto scheme
Per-vault, adapting the `age`/`sops`/PGP multi-recipient pattern (see Per-vault, adapting the `age`/`sops`/PGP multi-recipient pattern (see
README's "Core model") to libsodium primitives already used elsewhere in README's "Core model") to libsodium primitives:
this project family:
- **Payload encryption**: `crypto_secretbox` (XSalsa20-Poly1305), same - **Payload encryption**: `crypto_secretbox` (XSalsa20-Poly1305). A fresh
primitive `wisp` uses for its blobs. A fresh random 256-bit symmetric random 256-bit symmetric key per push.
key per push.
- **Key wrapping**: for each recipient granted access, the vault's - **Key wrapping**: for each recipient granted access, the vault's
symmetric key is sealed to that recipient's X25519 public key via symmetric key is sealed to that recipient's X25519 public key via
`crypto_box` (or `crypto_box_seal` if the pusher's own identity `crypto_box_seal` — anonymous sealing, since the server has no business
shouldn't need to be verifiable by the server — anonymous sealing, matches knowing which specific identity wrapped a copy beyond the recipient it's
how `flit`'s `Identity.seal()` already works, since the server has no addressed to.
business knowing which recipient's key was used to encrypt which - **Identity**: Ed25519 signing keypair per client, with the standard
wrapped copy beyond the recipient it's addressed to). sign-to-curve25519 conversion (`crypto_sign_ed25519_pk_to_curve25519` /
- **Identity**: Ed25519 signing keypair per client, with the same
sign-to-curve25519 conversion `flit`/`waste-go` already do
(`crypto_sign_ed25519_pk_to_curve25519` /
`..._sk_to_curve25519`) to derive the X25519 keypair used for the `..._sk_to_curve25519`) to derive the X25519 keypair used for the
box-sealing above. One identity type, reused for both signing (proving box-sealing above. One identity type, reused for both signing (proving
who's pushing/pulling, for the access log) and encryption (wrapping), who's pushing/pulling, for the access log) and encryption (wrapping).
exactly like the existing projects already do. - **Push authentication**: every push is signed by the pusher's Ed25519
- **Push authentication**: a push should be signed by the pusher's key so the access log records a real identity, not just "someone with
Ed25519 key so the access log records a real identity, not just "someone write access to this vault." Whether *anyone* with any registered
with write access to this vault." Whether *anyone* with any registered
identity can push to a vault they're a recipient of, or whether push identity can push to a vault they're a recipient of, or whether push
requires a separate "can write" grant distinct from "can read," is an requires a separate "can write" grant distinct from "can read," was an
open question below. open question at design time — resolved as "read implies write" for v1
(see Open Questions).
## Data model ## Data model
Flat schema, matching the minimalism already established in `wisp` and Flat schema — no unnecessary normalization; a vault is identified by its
`npm-statuspage` (no unnecessary normalization — a vault is identified by own key string, not a foreign-keyed `projects`/`environments` pair:
its own key string, not a foreign-keyed `projects`/`environments` pair):
```sql ```sql
CREATE TABLE IF NOT EXISTS vaults ( CREATE TABLE IF NOT EXISTS vaults (
vault_key TEXT PRIMARY KEY, -- e.g. "wisp/production" — free-form, app-chosen vault_key TEXT PRIMARY KEY, -- e.g. "myapp/production" — free-form, app-chosen
ciphertext BLOB NOT NULL, -- crypto_secretbox(payload, vault_symmetric_key) ciphertext BLOB NOT NULL, -- crypto_secretbox(payload, vault_symmetric_key)
nonce BLOB NOT NULL, nonce BLOB NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
@@ -76,8 +69,8 @@ CREATE TABLE IF NOT EXISTS vaults (
); );
CREATE TABLE IF NOT EXISTS recipients ( CREATE TABLE IF NOT EXISTS recipients (
recipient_id TEXT PRIMARY KEY, -- short label-derived slug, e.g. "wisp-prod-deploy" recipient_id TEXT PRIMARY KEY, -- short label-derived slug, e.g. "prod-deploy-a1b2c3"
label TEXT NOT NULL, -- human-readable, e.g. "wisp production deploy key" label TEXT NOT NULL, -- human-readable, e.g. "production deploy key"
public_key TEXT NOT NULL, -- hex Ed25519 pubkey public_key TEXT NOT NULL, -- hex Ed25519 pubkey
created_at INTEGER NOT NULL created_at INTEGER NOT NULL
); );
@@ -138,49 +131,47 @@ revocation is happening because a machine may have been compromised,
follow it with `keep push` to actually rotate the secret values, not just follow it with `keep push` to actually rotate the secret values, not just
the grant. the grant.
## CLI surface (sketch) ## CLI surface
``` ```
keep identity init -- generate + persist a local Ed25519 keypair (~/.keep/identity) keep identity init -- generate + persist a local Ed25519 keypair (~/.keep/identity.json)
keep identity show -- print this machine's public key, for registering as a recipient keep identity show -- print this machine's public key, for registering as a recipient
keep identity set-id <recipient-id> -- record the id an admin assigned after registering this identity
keep push <vault> [--file .env] -- encrypt + upload; requires this identity to already be a grantee keep push <vault> [--file .env] -- encrypt + upload; requires this identity to already be a grantee
keep pull <vault> [--format=env|json] -- fetch + decrypt; requires a grant for this identity keep pull <vault> [--format env|json] [--out <path>] -- fetch + decrypt; requires a grant for this identity
keep grant <vault> <recipient-id> -- add a new recipient to a vault you already have access to
keep log <vault> -- tail the access log for one vault
# admin operations — see below for how these are authenticated # admin operations — gated by ADMIN_PASSWORD, see below
keep recipient add --label "..." --pubkey <hex> keep recipient add --label "..." --pubkey <hex>
keep recipient list keep recipient list
keep recipient remove <recipient_id> keep recipient remove <recipient-id>
keep grant <vault> <recipient_id> keep revoke <vault> <recipient-id>
keep revoke <vault> <recipient_id>
keep log <vault> -- tail the access log for one vault
``` ```
### Admin operations need their own authorization story ### Admin operations need their own authorization story
Adding/removing recipients and granting/revoking vault access is a Adding/removing recipients and revoking vault access is a different trust
different trust level than pushing/pulling a specific vault you already level than pushing/pulling a specific vault you already have a grant for.
have a grant for. Options, in rough order of how much new infrastructure Options considered, in rough order of how much new infrastructure they
they cost: cost:
1. **Bootstrap admin identity**: the first recipient ever registered is 1. **Bootstrap admin identity**: the first recipient ever registered is
implicitly the admin; admin operations require a signature from that implicitly the admin; admin operations require a signature from that
identity. Cheapest — no new credential type — but doesn't scale past identity. Cheapest — no new credential type — but doesn't scale past
"it's just me" without more thought. "it's just me" without more thought.
2. **Separate `ADMIN_PASSWORD`**, same pattern as `wisp`'s proposed 2. **Separate `ADMIN_PASSWORD`** — a shared password distinct from any
invite-links admin gate and `npm-statuspage`'s admin panel (both recipient identity, gating a small admin HTTP surface. This is what
already built/proposed as of this writing) — a shared password got built: same shape as a password-gated admin panel, disabled
distinct from any recipient identity, gating a small admin HTTP entirely (503) rather than boot-failing when unset.
surface (could be CLI-driven hitting password-gated endpoints, or a
minimal web page like npm-statuspage's).
3. **Per-vault admin grants** — a `vault_grants.can_admin` boolean 3. **Per-vault admin grants** — a `vault_grants.can_admin` boolean
alongside read access, so admin authority is scoped per-vault instead alongside read access, so admin authority is scoped per-vault instead
of global. More correct, more to build; probably not v1. of global. More correct, more to build; not v1.
Leaning toward (2) for v1 — it's proven twice already elsewhere in this Went with (2) — a global admin password is an acceptable trust model for
project family, and a global admin password is an acceptable trust model "one person or small team running their own homelab," which is the
for "one person or small team running their own homelab," which is the actual scale this is built for.
actual scale this is being built for.
## Deploy integration ## Deploy integration
@@ -189,18 +180,19 @@ of assuming a hand-copied `.env` already exists on the target machine:
```bash ```bash
#!/usr/bin/env bash #!/usr/bin/env bash
# top of an existing deploy-*.sh script, e.g. waste-go's deploy-daemon.sh
set -euo pipefail set -euo pipefail
keep pull wisp/production --format=env > .env keep pull myapp/production --format env --out .env
# ...rest of the existing deploy script, unchanged, now consuming a # ...rest of an existing deploy script, unchanged, now consuming a
# freshly-pulled .env instead of one that was manually placed there # freshly-pulled .env instead of one that was manually placed there
``` ```
Requires the deploy machine to have a `keep` identity already registered Requires the deploy machine to have a `keep` identity already registered
and granted access to the relevant vault — a one-time setup step per and granted access to the relevant vault — a one-time setup step per
machine (`keep identity init` once, then an admin grants that machine's machine (`keep identity init` once, then an admin grants that machine's
public key access to whichever vaults it needs). public key access to whichever vaults it needs). See
[INTEGRATION.md](./INTEGRATION.md) for where this pattern does and
doesn't fit an existing deploy setup.
## Security considerations ## Security considerations
@@ -210,38 +202,50 @@ public key access to whichever vaults it needs).
- **Grant/revoke and push must not race.** If a push and a grant/revoke - **Grant/revoke and push must not race.** If a push and a grant/revoke
happen concurrently, the re-wrap step in `push` (which reads current happen concurrently, the re-wrap step in `push` (which reads current
`vault_grants` and re-wraps for all of them) must see a consistent `vault_grants` and re-wraps for all of them) must see a consistent
snapshot — wrap the read-grants-then-write-wrapped-keys sequence in a snapshot — the read-grants-then-write-wrapped-keys sequence runs inside
single SQLite transaction, same discipline as wisp's atomic confirm-token a single SQLite transaction.
consumption.
- **The access log is genuinely useful, not just decorative** — the - **The access log is genuinely useful, not just decorative** — the
README's whole pitch versus a static `age`-encrypted file in git is README's whole pitch versus a static `age`-encrypted file in git is
"you can tell who actually pulled what, when." Make sure `pull` "you can tell who actually pulled what, when." `pull` failures
failures (wrong/no grant) are *also* logged, not just successes — a (wrong/no grant) are logged too, not just successes — a spike of failed
spike of failed pulls from an unexpected identity is exactly the kind pulls from an unexpected identity is exactly the kind of signal this
of signal this feature exists to surface. feature exists to surface.
- **Recipient private keys are the actual crown jewels**, same as - **Recipient private keys are the actual crown jewels.** `keep identity
`flit`/`waste-go`'s existing identity model. `keep identity init` init` persists the local keypair at `~/.keep/identity.json`
should follow the same local-storage discipline those already use (mode `0o600`, directory `0o700`) — not committed anywhere, not
(`~/.flit/`-style, not committed anywhere, not transmitted anywhere transmitted anywhere except the public half.
except the public half).
## What was verified end-to-end
Two independent local identities against a real running server (and
separately against the built Docker image): registration, a push,
pulling as the pusher, pulling as an ungranted second identity (correctly
rejected), granting the second identity access without re-pushing,
pulling as the now-granted second identity (correctly decrypts the same
underlying secret via its own sealed key copy), admin revocation, a
subsequent rotation confirming the revoked identity is excluded from the
new wrap set, and rejection of both missing and malformed signed-request
auth.
One real implementation bug caught during this process, worth recording:
Express's `req.path` inside a sub-router is relative to that router's
mount point (e.g. `/vaults/x/pull` instead of `/api/vaults/x/pull`),
which would have silently mismatched a client that signs the full request
path. Fixed by verifying against `req.originalUrl` (path portion only)
instead, which stays consistent regardless of router nesting.
## Open questions ## Open questions
- **Version history**: keep only the latest payload per vault (simplest, - **Version history**: keep only the latest payload per vault (what got
matches the "this predates rollback needs" scope), or retain N previous built), or retain N previous versions for `keep pull --version=N`
versions for `keep pull --version=N` rollback? Leaning toward: skip for rollback? Skipped for v1, revisit if a real rollback need shows up.
v1, revisit if a real rollback need shows up.
- **Push authorization**: does having a *read* grant (`vault_grants` row) - **Push authorization**: does having a *read* grant (`vault_grants` row)
imply push rights too, or is push a separate capability? Leaning toward: imply push rights too, or is push a separate capability? Went with
read implies write for v1 (simpler, matches "small team, own homelab" "read implies write" for v1 — a `can_write` flag on `vault_grants` is
scale) — a `can_write` flag on `vault_grants` is the natural extension the natural extension if that turns out to be wrong.
if that turns out to be wrong.
- **Per-secret-key granularity**: v1 treats a vault as one opaque - **Per-secret-key granularity**: v1 treats a vault as one opaque
`.env`-shaped blob (matches the actual current pain point: whole files `.env`-shaped blob (matches the actual pain point this was built for:
get copied around, not individual keys). If a use case emerges for whole files get copied around, not individual keys). If a use case
"grant access to just the DB password, not the whole bundle," that's a emerges for "grant access to just one value, not the whole bundle,"
genuinely different data model (per-key rows, each separately wrapped) that's a genuinely different data model (per-key rows, each separately
— don't half-build it speculatively now. wrapped) — don't half-build it speculatively.
- **Admin auth mechanism** — see the three options under CLI surface
above; needs a decision before build, not deferred to implementation
time, since it shapes the admin API surface.

87
INTEGRATION.md Normal file
View File

@@ -0,0 +1,87 @@
# 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.
## 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.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 explewd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

118
README.md
View File

@@ -1,17 +1,16 @@
# keep # keep
A self-hosted, end-to-end encrypted secrets store for deploy scripts. 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 decrypted locally — the server never sees plaintext values, and never
sees the key that would let it. sees the key that would let it.
## Why ## Why
Across this project family — `goonk`, `wisp`, `npm-statuspage`, `flit`, Across a small family of self-hosted projects, every repo tends to have
`waste-go` — every repo has its own `.env`/`.env.example` pair, and every its own `.env`/`.env.example` pair, and every one of those `.env` files
one of those `.env` files exists today by being hand-copied onto whatever exists today by being hand-copied onto whatever machine needs it. That's
machine needs it. That's a real, already-felt annoyance, not a a real, already-felt annoyance, not a hypothetical one:
hypothetical one:
- Rotating a credential means finding and updating every machine that has - Rotating a credential means finding and updating every machine that has
a copy, by hand. 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 access can pull the update) and **an access log** (who/what actually
fetched a secret bundle, and when — a git history shows content fetched a secret bundle, and when — a git history shows content
changes, not access). changes, not access).
- **Not wisp.** wisp is ephemeral and single-retrieval by design — the - **Not an ephemeral file-drop tool.** Something built for single-retrieval,
opposite of what a secret needs to be. A deploy script needs to fetch self-destructing file sharing is the opposite of what a secret needs to
the same bundle on every deploy, not once and then have it vanish. be — a deploy script needs to fetch the same bundle on every deploy, not
once and then have it vanish.
## Core model ## 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: 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) 1. Each client (a developer's machine, a deploy script running on a VPS)
has its own Ed25519/X25519 keypair — same identity model already has its own Ed25519/X25519 keypair.
built twice over in `flit` and `waste-go`. 2. A vault (`myapp/production`, `myapp/staging`, whatever key) holds one
2. A vault (`wisp/production`, `goonk/production`, whatever key) holds one
encrypted blob — the actual secret bundle, encrypted under a random encrypted blob — the actual secret bundle, encrypted under a random
symmetric key. symmetric key.
3. That symmetric key is wrapped separately for every recipient granted 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, symmetric key, re-wrapped for every currently-granted recipient. Cheap,
since the recipient list rarely changes. since the recipient list rarely changes.
Full design — data model, the atomic-grant/revoke details, the CLI Full design — data model, the atomic-grant/revoke details, and what this
surface, and what this explicitly does and doesn't protect against — is explicitly does and doesn't protect against — is in
in [IMPLEMENTATION.md](./IMPLEMENTATION.md). [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 ## 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).

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
services:
app:
image: ${IMAGE}
container_name: keep
restart: unless-stopped
pull_policy: always
ports:
- "${HOST_PORT:-3050}:3050"
environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- REQUEST_MAX_SKEW_SECONDS=${REQUEST_MAX_SKEW_SECONDS:-300}
volumes:
# Named volume, not a bind mount — the SQLite DB holding vaults,
# recipients, and grants must survive redeploys.
- keep-data:/app/data
volumes:
keep-data:

1954
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "keep",
"version": "0.1.0",
"license": "MIT",
"type": "module",
"bin": {
"keep": "./dist/cli/index.js"
},
"scripts": {
"dev:server": "tsx watch --env-file=.env src/server/index.ts",
"cli": "tsx src/cli/index.ts",
"build": "tsc",
"start": "node --env-file=.env dist/server/index.js"
},
"dependencies": {
"express": "^4.19.2",
"better-sqlite3": "^11.3.0",
"libsodium-wrappers": "^0.7.15"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"@types/better-sqlite3": "^7.6.11",
"@types/libsodium-wrappers": "^0.7.14",
"tsx": "^4.15.0",
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,21 @@
import { loadOrCreateIdentity, setRecipientId } from '../identityStore.js';
export async function identityInit(): Promise<void> {
const { identity, created } = await loadOrCreateIdentity();
console.log(created ? 'identity created.' : 'identity already exists.');
console.log(`public key: ${identity.id}`);
console.log(`\nSend this public key to whoever runs 'keep recipient add' for this vault server.`);
console.log(`They'll give you back a recipient id — set it locally with:`);
console.log(` keep identity set-id <recipient-id>`);
}
export async function identityShow(): Promise<void> {
const { identity, recipientId } = await loadOrCreateIdentity();
console.log(`public key: ${identity.id}`);
console.log(`recipient id: ${recipientId ?? '(not set — see: keep identity set-id)'}`);
}
export function identitySetId(recipientId: string): void {
setRecipientId(recipientId);
console.log(`recipient id set to: ${recipientId}`);
}

View File

@@ -0,0 +1,20 @@
import { adminFetch, expectOk } from '../signedFetch.js';
export async function recipientAdd(label: string, publicKey: string): Promise<void> {
const data = await expectOk(await adminFetch('POST', '/api/admin/recipients', { label, publicKey }));
console.log(`recipient created: ${data.recipientId}`);
console.log(`Give this id back to whoever owns this identity — they set it with 'keep identity set-id ${data.recipientId}'.`);
}
export async function recipientList(): Promise<void> {
const data = await expectOk(await adminFetch('GET', '/api/admin/recipients'));
if (!data.length) { console.log('no recipients yet.'); return; }
for (const r of data) {
console.log(`${r.recipientId} ${r.label} ${r.publicKey.slice(0, 16)}`);
}
}
export async function recipientRemove(recipientId: string): Promise<void> {
await expectOk(await adminFetch('DELETE', `/api/admin/recipients/${encodeURIComponent(recipientId)}`));
console.log(`recipient removed: ${recipientId} (and all their vault grants)`);
}

110
src/cli/commands/vault.ts Normal file
View File

@@ -0,0 +1,110 @@
import fs from 'node:fs';
import { requireIdentityAndRecipientId } from '../identityStore.js';
import { signedFetch, adminFetch, expectOk } from '../signedFetch.js';
import { parseEnv, serializeEnv } from '../envFormat.js';
import {
ready,
Identity,
generateSymmetricKey,
secretboxEncrypt,
secretboxDecrypt,
enc,
} from '../../shared/crypto.js';
export async function vaultPush(vaultKey: string, file: string): Promise<void> {
await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId();
if (!fs.existsSync(file)) throw new Error(`file not found: ${file}`);
const values = parseEnv(fs.readFileSync(file, 'utf8'));
const payload = enc.fromUtf8(JSON.stringify(values));
const existsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/exists`));
const wrapFor = new Map<string, string>(); // recipientId -> publicKey
wrapFor.set(recipientId, identity.id);
if (existsRes.exists) {
const recipientsRes = await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/recipients`);
if (!recipientsRes.ok) {
const body = await recipientsRes.json().catch(() => ({}));
throw new Error(body.error ?? `no access to vault '${vaultKey}' — ask an existing grantee to run 'keep grant ${vaultKey} ${recipientId}'`);
}
const current = await recipientsRes.json() as { recipientId: string; publicKey: string }[];
for (const r of current) if (r.publicKey) wrapFor.set(r.recipientId, r.publicKey);
}
const symmetricKey = generateSymmetricKey();
const { nonce, ciphertext } = secretboxEncrypt(symmetricKey, payload);
const wrappedKeys: Record<string, string> = {};
for (const [rid, pubHex] of wrapFor) {
wrappedKeys[rid] = enc.toBase64(Identity.sealFor(pubHex, symmetricKey));
}
await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/push`, {
ciphertext: enc.toBase64(ciphertext),
nonce: enc.toBase64(nonce),
wrappedKeys,
}));
console.log(`pushed '${vaultKey}' — ${Object.keys(values).length} key${Object.keys(values).length === 1 ? '' : 's'}, wrapped for ${wrapFor.size} recipient${wrapFor.size === 1 ? '' : 's'}.`);
}
export async function vaultPull(vaultKey: string, format: 'env' | 'json', outFile?: string): Promise<void> {
await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId();
const data = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/pull`));
const symmetricKey = identity.openSealed(enc.fromBase64(data.wrappedKey));
if (!symmetricKey) throw new Error('failed to unwrap this vault\'s key — wrong identity, or the grant is stale');
const plaintext = secretboxDecrypt(symmetricKey, enc.fromBase64(data.nonce), enc.fromBase64(data.ciphertext));
const values = JSON.parse(enc.toUtf8(plaintext)) as Record<string, string>;
const out = format === 'json' ? JSON.stringify(values, null, 2) : serializeEnv(values);
if (outFile) {
fs.writeFileSync(outFile, out);
console.error(`wrote ${Object.keys(values).length} key(s) to ${outFile}`);
} else {
process.stdout.write(out);
}
}
export async function vaultGrant(vaultKey: string, targetRecipientId: string): Promise<void> {
await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId();
const pullRes = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/pull`));
const symmetricKey = identity.openSealed(enc.fromBase64(pullRes.wrappedKey));
if (!symmetricKey) throw new Error("failed to unwrap this vault's key — can't grant access to someone else without it");
const recipientsRes = await expectOk(await signedFetch(identity, recipientId, 'GET', '/api/recipients'));
const target = (recipientsRes as { recipientId: string; publicKey: string }[]).find(r => r.recipientId === targetRecipientId);
if (!target) throw new Error(`unknown recipient id: ${targetRecipientId} (ask an admin to 'keep recipient add' them first)`);
const wrappedKey = enc.toBase64(Identity.sealFor(target.publicKey, symmetricKey));
await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/grant`, {
recipientId: targetRecipientId,
wrappedKey,
}));
console.log(`granted '${vaultKey}' to ${targetRecipientId}.`);
}
export async function vaultRevoke(vaultKey: string, targetRecipientId: string): Promise<void> {
await expectOk(await adminFetch('DELETE', `/api/admin/vaults/${encodeURIComponent(vaultKey)}/grants/${encodeURIComponent(targetRecipientId)}`));
console.log(`revoked ${targetRecipientId}'s access to '${vaultKey}'.`);
console.log(`Note: this stops future pulls only. If this was a compromise response, also run 'keep push ${vaultKey}' with rotated values.`);
}
export async function vaultLog(vaultKey: string): Promise<void> {
await ready();
const { identity, recipientId } = await requireIdentityAndRecipientId();
const entries = await expectOk(await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/log`));
if (!entries.length) { console.log('no access log entries yet.'); return; }
for (const e of entries as { recipient_id: string; action: string; accessed_at: number }[]) {
console.log(`${new Date(e.accessed_at * 1000).toISOString()} ${e.action.padEnd(6)} ${e.recipient_id}`);
}
}

22
src/cli/envFormat.ts Normal file
View File

@@ -0,0 +1,22 @@
export function parseEnv(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of text.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
export function serializeEnv(values: Record<string, string>): string {
return Object.entries(values)
.map(([k, v]) => `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`)
.join('\n') + '\n';
}

53
src/cli/identityStore.ts Normal file
View File

@@ -0,0 +1,53 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { Identity } from '../shared/crypto.js';
const DIR = path.join(os.homedir(), '.keep');
const FILE = path.join(DIR, 'identity.json');
interface StoredIdentity {
seedHex: string;
// Assigned by whoever runs `keep recipient add` after registering this
// identity's public key — not derivable from the keypair itself, so it
// has to be stored once known via `keep identity set-id`.
recipientId?: string;
}
function readStored(): StoredIdentity | null {
if (!fs.existsSync(FILE)) return null;
return JSON.parse(fs.readFileSync(FILE, 'utf8')) as StoredIdentity;
}
function writeStored(data: StoredIdentity): void {
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
fs.writeFileSync(FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
}
export async function loadOrCreateIdentity(): Promise<{ identity: Identity; recipientId: string | null; created: boolean }> {
const stored = readStored();
if (stored) {
return { identity: await Identity.fromSeedHex(stored.seedHex), recipientId: stored.recipientId ?? null, created: false };
}
const identity = await Identity.generate();
writeStored({ seedHex: identity.seedHex });
return { identity, recipientId: null, created: true };
}
export function setRecipientId(recipientId: string): void {
const stored = readStored();
if (!stored) throw new Error("no local identity — run 'keep identity init' first");
writeStored({ ...stored, recipientId });
}
export async function requireIdentityAndRecipientId(): Promise<{ identity: Identity; recipientId: string }> {
const stored = readStored();
if (!stored) throw new Error("no local identity — run 'keep identity init' first");
if (!stored.recipientId) {
throw new Error(
"this identity has no recipient id set yet — after an admin registers your public key " +
"('keep identity show' to get it), run 'keep identity set-id <recipient-id>' with the id they give you back",
);
}
return { identity: await Identity.fromSeedHex(stored.seedHex), recipientId: stored.recipientId };
}

61
src/cli/index.ts Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env node
import { identityInit, identityShow, identitySetId } from './commands/identity.js';
import { recipientAdd, recipientList, recipientRemove } from './commands/recipient.js';
import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog } from './commands/vault.js';
function flag(args: string[], name: string, fallback?: string): string | undefined {
const i = args.indexOf(`--${name}`);
return i !== -1 ? args[i + 1] : fallback;
}
async function main(): Promise<void> {
const [, , cmd, sub, ...rest] = process.argv;
try {
if (cmd === 'identity') {
if (sub === 'init') return await identityInit();
if (sub === 'show') return await identityShow();
if (sub === 'set-id') return identitySetId(rest[0]);
}
if (cmd === 'recipient') {
if (sub === 'add') return await recipientAdd(flag(rest, 'label')!, flag(rest, 'pubkey')!);
if (sub === 'list') return await recipientList();
if (sub === 'remove') return await recipientRemove(rest[0]);
}
if (cmd === 'push') return await vaultPush(sub, flag(rest, 'file', '.env')!);
if (cmd === 'pull') return await vaultPull(sub, (flag(rest, 'format', 'env') as 'env' | 'json'), flag(rest, 'out'));
if (cmd === 'grant') return await vaultGrant(sub, rest[0]);
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
if (cmd === 'log') return await vaultLog(sub);
printUsage();
process.exitCode = cmd ? 1 : 0;
} catch (err) {
console.error(`error: ${err instanceof Error ? err.message : err}`);
process.exitCode = 1;
}
}
function printUsage(): void {
console.log(`keep — self-hosted, end-to-end encrypted secrets sync
keep identity init
keep identity show
keep identity set-id <recipient-id>
keep push <vault> [--file .env]
keep pull <vault> [--format env|json] [--out <path>]
keep grant <vault> <recipient-id>
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
keep log <vault>
keep recipient add --label <label> --pubkey <hex> (admin)
keep recipient list (admin)
keep recipient remove <recipient-id> (admin)
Env: KEEP_SERVER_URL (default http://localhost:3050), KEEP_ADMIN_PASSWORD (for admin commands)`);
}
main();

48
src/cli/signedFetch.ts Normal file
View File

@@ -0,0 +1,48 @@
import { Identity, signingString, enc } from '../shared/crypto.js';
export function serverUrl(): string {
return process.env.KEEP_SERVER_URL ?? 'http://localhost:3050';
}
export async function signedFetch(
identity: Identity,
recipientId: string,
method: string,
path: string,
body?: unknown,
): Promise<Response> {
const bodyBytes = body !== undefined ? enc.fromUtf8(JSON.stringify(body)) : new Uint8Array();
const timestamp = Math.floor(Date.now() / 1000);
const toSign = signingString(method, path, timestamp, bodyBytes);
const signature = enc.toHex(identity.sign(enc.fromUtf8(toSign)));
return fetch(`${serverUrl()}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Recipient-Id': recipientId,
'X-Signature': signature,
'X-Timestamp': String(timestamp),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
export async function adminFetch(method: string, path: string, body?: unknown): Promise<Response> {
const password = process.env.KEEP_ADMIN_PASSWORD;
if (!password) throw new Error('KEEP_ADMIN_PASSWORD is not set');
return fetch(`${serverUrl()}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'X-Admin-Password': password,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
export async function expectOk(res: Response): Promise<any> {
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error ?? `request failed: ${res.status}`);
return data;
}

47
src/server/adminRoutes.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Router } from 'express';
import { randomBytes } from 'node:crypto';
import { listRecipients, createRecipient, deleteRecipient, deleteGrant, getAccessLog, logAccess } from './db.js';
import { requireAdminAuth } from './auth.js';
export const router = Router();
router.use(requireAdminAuth);
function newRecipientId(label: string): string {
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
const suffix = randomBytes(3).toString('hex');
return slug ? `${slug}-${suffix}` : suffix;
}
router.get('/recipients', (_req, res) => {
res.json(listRecipients().map(r => ({ recipientId: r.recipient_id, label: r.label, publicKey: r.public_key, createdAt: r.created_at })));
});
router.post('/recipients', (req, res) => {
const body = req.body as { label?: string; publicKey?: string };
if (!body.label || !body.publicKey) {
res.status(400).json({ error: 'label and publicKey are required' });
return;
}
const recipientId = newRecipientId(body.label);
createRecipient(recipientId, body.label, body.publicKey);
res.json({ recipientId });
});
router.delete('/recipients/:id', (req, res) => {
deleteRecipient(req.params.id);
res.json({ ok: true });
});
// Revocation is pure metadata deletion — no crypto material is touched,
// which is exactly why admin alone (no vault access of their own
// required) can perform it, unlike `grant`.
router.delete('/vaults/:key/grants/:recipientId', (req, res) => {
deleteGrant(req.params.key, req.params.recipientId);
logAccess(req.params.key, req.params.recipientId, 'revoke');
res.json({ ok: true });
});
router.get('/vaults/:key/log', (req, res) => {
res.json(getAccessLog(req.params.key));
});

77
src/server/auth.ts Normal file
View File

@@ -0,0 +1,77 @@
// Signed-request authentication for recipient-facing endpoints
// (push/pull/grant/log) — distinct from the ADMIN_PASSWORD header used by
// admin-only routes (recipient add/remove, revoke). A recipient proves
// their identity by signing a canonical string derived from the request
// itself; no session, no cookie, stateless like every other password/
// signature check in this project family.
import type { Request, Response, NextFunction } from 'express';
import { getRecipient } from './db.js';
import { Identity, signingString } from '../shared/crypto.js';
import { config } from './config.js';
export interface AuthedRequest extends Request {
recipientId?: string;
rawBody?: Buffer;
}
export async function requireRecipientAuth(req: AuthedRequest, res: Response, next: NextFunction): Promise<void> {
const recipientId = req.get('X-Recipient-Id');
const signature = req.get('X-Signature');
const timestampHeader = req.get('X-Timestamp');
if (!recipientId || !signature || !timestampHeader) {
res.status(401).json({ error: 'missing auth headers' });
return;
}
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > config.requestMaxSkewSeconds) {
res.status(401).json({ error: 'timestamp out of range' });
return;
}
const recipient = getRecipient(recipientId);
if (!recipient) {
res.status(401).json({ error: 'unknown recipient' });
return;
}
// req.path is relative to whichever router mounted this middleware
// (e.g. "/vaults/x/pull" inside a router mounted at "/api"), which would
// silently disagree with a client that signs the full request path.
// req.originalUrl is always the full path as actually requested,
// regardless of router nesting — strip the query string, since the
// client doesn't sign one.
const fullPath = req.originalUrl.split('?')[0];
const body = req.rawBody ?? Buffer.alloc(0);
const expected = signingString(req.method, fullPath, timestamp, body);
let sigBytes: Uint8Array;
try {
sigBytes = Buffer.from(signature, 'hex');
} catch {
res.status(401).json({ error: 'bad signature encoding' });
return;
}
const ok = Identity.verify(recipient.public_key, Buffer.from(expected, 'utf8'), sigBytes);
if (!ok) {
res.status(401).json({ error: 'signature verification failed' });
return;
}
req.recipientId = recipientId;
next();
}
export function requireAdminAuth(req: Request, res: Response, next: NextFunction): void {
if (!config.adminPassword) {
res.status(503).json({ error: 'admin disabled — ADMIN_PASSWORD not set' });
return;
}
if (req.get('X-Admin-Password') !== config.adminPassword) {
res.status(401).json({ error: 'bad password' });
return;
}
next();
}

12
src/server/config.ts Normal file
View File

@@ -0,0 +1,12 @@
import path from 'node:path';
export const config = {
port: parseInt(process.env.PORT ?? '3050', 10),
dataDir: process.env.DATA_DIR ?? path.resolve('data'),
// Gates recipient management + revoke. Unset disables admin routes
// entirely (503) rather than refusing to start — push/pull/grant for
// already-registered recipients keep working regardless.
adminPassword: process.env.ADMIN_PASSWORD || null,
// Replay-protection window for signed requests.
requestMaxSkewSeconds: parseInt(process.env.REQUEST_MAX_SKEW_SECONDS ?? '300', 10),
};

159
src/server/db.ts Normal file
View File

@@ -0,0 +1,159 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { config } from './config.js';
fs.mkdirSync(config.dataDir, { recursive: true });
export const db = new Database(path.join(config.dataDir, 'keep.db'));
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS vaults (
vault_key TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL,
nonce BLOB NOT NULL,
updated_at INTEGER NOT NULL,
updated_by TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS recipients (
recipient_id TEXT PRIMARY KEY,
label TEXT NOT NULL,
public_key TEXT NOT NULL,
created_at INTEGER NOT NULL
);
-- Deleting a row IS the revocation for that (vault, recipient) pair.
CREATE TABLE IF NOT EXISTS vault_grants (
vault_key TEXT NOT NULL,
recipient_id TEXT NOT NULL,
wrapped_key TEXT NOT NULL, -- base64 crypto_box_seal(vault_symmetric_key, recipient_pubkey)
granted_at INTEGER NOT NULL,
PRIMARY KEY (vault_key, recipient_id)
);
CREATE TABLE IF NOT EXISTS access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
vault_key TEXT NOT NULL,
recipient_id TEXT NOT NULL,
action TEXT NOT NULL,
accessed_at INTEGER NOT NULL
);
`);
export interface VaultRow {
vault_key: string;
ciphertext: Buffer;
nonce: Buffer;
updated_at: number;
updated_by: string;
}
export interface RecipientRow {
recipient_id: string;
label: string;
public_key: string;
created_at: number;
}
export interface GrantRow {
vault_key: string;
recipient_id: string;
wrapped_key: string;
granted_at: number;
}
export function getVault(vaultKey: string): VaultRow | undefined {
return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined;
}
export function upsertVault(vaultKey: string, ciphertext: Buffer, nonce: Buffer, updatedBy: string): void {
db.prepare(
`INSERT INTO vaults (vault_key, ciphertext, nonce, updated_at, updated_by)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(vault_key) DO UPDATE SET
ciphertext = excluded.ciphertext, nonce = excluded.nonce,
updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
).run(vaultKey, ciphertext, nonce, Math.floor(Date.now() / 1000), updatedBy);
}
export function getRecipient(recipientId: string): RecipientRow | undefined {
return db.prepare(`SELECT * FROM recipients WHERE recipient_id = ?`).get(recipientId) as RecipientRow | undefined;
}
export function listRecipients(): RecipientRow[] {
return db.prepare(`SELECT * FROM recipients ORDER BY created_at ASC`).all() as RecipientRow[];
}
export function createRecipient(recipientId: string, label: string, publicKey: string): void {
db.prepare(
`INSERT INTO recipients (recipient_id, label, public_key, created_at) VALUES (?, ?, ?, ?)`,
).run(recipientId, label, publicKey, Math.floor(Date.now() / 1000));
}
export function deleteRecipient(recipientId: string): void {
const tx = db.transaction(() => {
db.prepare(`DELETE FROM recipients WHERE recipient_id = ?`).run(recipientId);
db.prepare(`DELETE FROM vault_grants WHERE recipient_id = ?`).run(recipientId);
});
tx();
}
export function hasGrant(vaultKey: string, recipientId: string): boolean {
const row = db.prepare(
`SELECT 1 FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`,
).get(vaultKey, recipientId);
return row != null;
}
export function getGrant(vaultKey: string, recipientId: string): GrantRow | undefined {
return db.prepare(
`SELECT * FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`,
).get(vaultKey, recipientId) as GrantRow | undefined;
}
export function listGrantsForVault(vaultKey: string): GrantRow[] {
return db.prepare(`SELECT * FROM vault_grants WHERE vault_key = ?`).all(vaultKey) as GrantRow[];
}
export function setGrant(vaultKey: string, recipientId: string, wrappedKeyB64: string): void {
db.prepare(
`INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, granted_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(vault_key, recipient_id) DO UPDATE SET wrapped_key = excluded.wrapped_key, granted_at = excluded.granted_at`,
).run(vaultKey, recipientId, wrappedKeyB64, Math.floor(Date.now() / 1000));
}
// Used by `push` (rotation): replaces every wrapped-key row for a vault
// in one transaction, so a concurrent grant/revoke can't interleave with
// a partial rewrap and leave the vault in a mixed old/new-key state.
export function replaceAllGrantsForVault(vaultKey: string, wrappedByRecipientId: Map<string, string>): void {
const tx = db.transaction(() => {
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ?`).run(vaultKey);
const insert = db.prepare(
`INSERT INTO vault_grants (vault_key, recipient_id, wrapped_key, granted_at) VALUES (?, ?, ?, ?)`,
);
const now = Math.floor(Date.now() / 1000);
for (const [recipientId, wrappedKeyB64] of wrappedByRecipientId) {
insert.run(vaultKey, recipientId, wrappedKeyB64, now);
}
});
tx();
}
export function deleteGrant(vaultKey: string, recipientId: string): void {
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`).run(vaultKey, recipientId);
}
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke'): void {
db.prepare(
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000));
}
export function getAccessLog(vaultKey: string, limit = 50): { recipient_id: string; action: string; accessed_at: number }[] {
return db.prepare(
`SELECT recipient_id, action, accessed_at FROM access_log WHERE vault_key = ? ORDER BY accessed_at DESC LIMIT ?`,
).all(vaultKey, limit) as { recipient_id: string; action: string; accessed_at: number }[];
}

34
src/server/index.ts Normal file
View File

@@ -0,0 +1,34 @@
import express from 'express';
import { config } from './config.js';
import { router as apiRouter } from './routes.js';
import { router as adminRouter } from './adminRoutes.js';
import type { AuthedRequest } from './auth.js';
const app = express();
// Capture the raw body bytes alongside the parsed JSON — signature
// verification needs to hash exactly what was sent, not a re-serialized
// version of the parsed object (which could differ in key order/spacing
// and produce a different hash than what the client signed).
app.use(express.json({
verify: (req, _res, buf) => {
(req as AuthedRequest).rawBody = Buffer.from(buf);
},
}));
// Must be registered before apiRouter — that router applies
// requireRecipientAuth to every path under /api via router.use(), which
// would otherwise swallow this unauthenticated health check too, since
// Express matches route registration order and app.use('/api', ...)
// matches /api/health as a prefix.
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.use('/api/admin', adminRouter);
app.use('/api', apiRouter);
app.listen(config.port, () => {
console.log(`[keep] listening on :${config.port}`);
if (!config.adminPassword) {
console.log('[keep] ADMIN_PASSWORD not set — /api/admin is disabled');
}
});

135
src/server/routes.ts Normal file
View File

@@ -0,0 +1,135 @@
import { Router } from 'express';
import {
getVault,
upsertVault,
hasGrant,
getGrant,
listGrantsForVault,
setGrant,
replaceAllGrantsForVault,
logAccess,
getAccessLog,
listRecipients,
} from './db.js';
import { requireRecipientAuth, type AuthedRequest } from './auth.js';
export const router = Router();
router.use(requireRecipientAuth);
// Recipient public keys aren't secret — knowing one doesn't grant access
// to anything — so this is recipient-authenticated (proves you're a
// registered identity) rather than admin-gated. Needed so a recipient can
// look up who else exists before granting them access to a vault.
router.get('/recipients', (_req, res) => {
res.json(listRecipients().map(r => ({ recipientId: r.recipient_id, label: r.label, publicKey: r.public_key })));
});
// Existence isn't secret — lets `keep push` distinguish "vault doesn't
// exist yet, fine to self-bootstrap" from "vault exists but I have no
// grant," which a bare 403 on /recipients can't tell apart.
router.get('/vaults/:key/exists', (req, res) => {
res.json({ exists: getVault(req.params.key) != null });
});
// Only current grantees of a vault can see who else has access to it —
// needed by `keep push` to know who to re-wrap the rotated key for.
router.get('/vaults/:key/recipients', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
if (!hasGrant(vaultKey, req.recipientId!)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
const grants = listGrantsForVault(vaultKey);
const byId = new Map(listRecipients().map(r => [r.recipient_id, r]));
res.json(grants.map(g => {
const r = byId.get(g.recipient_id);
return { recipientId: g.recipient_id, label: r?.label ?? null, publicKey: r?.public_key ?? null };
}));
});
router.get('/vaults/:key/pull', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
const recipientId = req.recipientId!;
const grant = getGrant(vaultKey, recipientId);
const vault = getVault(vaultKey);
if (!grant || !vault) {
logAccess(vaultKey, recipientId, 'pull'); // logged even on failure — a spike of failed pulls from an unexpected identity is exactly the signal this exists to surface
res.status(404).json({ error: 'no vault or no access' });
return;
}
logAccess(vaultKey, recipientId, 'pull');
res.json({
ciphertext: vault.ciphertext.toString('base64'),
nonce: vault.nonce.toString('base64'),
wrappedKey: grant.wrapped_key,
updatedAt: vault.updated_at,
updatedBy: vault.updated_by,
});
});
// Rotation: replaces the vault's ciphertext AND every recipient's wrapped
// key in one call — the client has already generated a fresh symmetric
// key and re-wrapped it for every currently-granted recipient (fetched
// via GET /vaults/:key/recipients first). Existing vaults require the
// pusher to already be a grantee (read implies write, v1 decision from
// IMPLEMENTATION.md); a brand-new vault key may be created by any
// registered recipient, who becomes its first grantee.
router.post('/vaults/:key/push', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
const recipientId = req.recipientId!;
const existing = getVault(vaultKey);
if (existing && !hasGrant(vaultKey, recipientId)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
const body = req.body as { ciphertext?: string; nonce?: string; wrappedKeys?: Record<string, string> };
if (!body.ciphertext || !body.nonce || !body.wrappedKeys || Object.keys(body.wrappedKeys).length === 0) {
res.status(400).json({ error: 'ciphertext, nonce, and at least one wrapped key are required' });
return;
}
upsertVault(vaultKey, Buffer.from(body.ciphertext, 'base64'), Buffer.from(body.nonce, 'base64'), recipientId);
replaceAllGrantsForVault(vaultKey, new Map(Object.entries(body.wrappedKeys)));
logAccess(vaultKey, recipientId, 'push');
res.json({ ok: true });
});
// Adds ONE new recipient to an existing vault using the CURRENT
// (unrotated) symmetric key — the granter must already hold a grant
// (meaning they can unwrap the current key locally) and supplies a fresh
// seal of that same key for the new recipient's public key. Does not
// touch the ciphertext or any other recipient's wrapped key.
router.post('/vaults/:key/grant', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
const granterId = req.recipientId!;
if (!hasGrant(vaultKey, granterId)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
const body = req.body as { recipientId?: string; wrappedKey?: string };
if (!body.recipientId || !body.wrappedKey) {
res.status(400).json({ error: 'recipientId and wrappedKey are required' });
return;
}
setGrant(vaultKey, body.recipientId, body.wrappedKey);
logAccess(vaultKey, granterId, 'grant');
res.json({ ok: true });
});
router.get('/vaults/:key/log', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
if (!hasGrant(vaultKey, req.recipientId!)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
res.json(getAccessLog(vaultKey));
});

127
src/shared/crypto.ts Normal file
View File

@@ -0,0 +1,127 @@
// Identity and sealing primitives — standard libsodium Ed25519/X25519
// primitives and sign-to-curve derivation, for Node rather than a browser
// client.
//
// libsodium-wrappers' published ESM build does a relative import
// ("./libsodium.mjs") that only resolves under bundler-style resolution
// (Vite, webpack) — it's broken under plain Node ESM, where "libsodium"
// is a separate node_modules package, not a sibling file. A purely
// browser-bundled use of the same package would never hit this. Forcing
// the CJS build via createRequire sidesteps the bug: CJS
// require('libsodium') resolves as a normal node_modules lookup instead
// of a broken relative path.
import { createRequire } from 'node:module';
import { createHash } from 'node:crypto';
const require = createRequire(import.meta.url);
const sodium = require('libsodium-wrappers') as typeof import('libsodium-wrappers');
export interface KeyPair {
publicKey: Uint8Array;
privateKey: Uint8Array;
}
export class Identity {
pub: Uint8Array;
priv: Uint8Array;
id: string; // hex Ed25519 pubkey
curvePriv: Uint8Array;
curvePub: Uint8Array;
constructor(kp: KeyPair) {
this.pub = kp.publicKey;
this.priv = kp.privateKey;
this.id = sodium.to_hex(this.pub);
this.curvePriv = sodium.crypto_sign_ed25519_sk_to_curve25519(this.priv);
this.curvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(this.pub);
}
static async generate(): Promise<Identity> {
await sodium.ready;
return new Identity(sodium.crypto_sign_keypair());
}
static async fromSeedHex(seedHex: string): Promise<Identity> {
await sodium.ready;
return new Identity(sodium.crypto_sign_seed_keypair(sodium.from_hex(seedHex)));
}
// First 32 bytes of the Ed25519 private key are the seed — the usual
// compact persistence convention for this kind of identity.
get seedHex(): string {
return sodium.to_hex(this.priv.slice(0, 32));
}
sign(data: Uint8Array): Uint8Array {
return sodium.crypto_sign_detached(data, this.priv);
}
static verify(pubHex: string, data: Uint8Array, sig: Uint8Array): boolean {
try {
return sodium.crypto_sign_verify_detached(sig, data, sodium.from_hex(pubHex));
} catch {
return false;
}
}
// Anonymous sealing (crypto_box_seal): the server never needs to know
// *who* wrapped a key for a recipient, only that it was wrapped
// correctly for them. Used to wrap a vault's symmetric key per
// recipient.
static sealFor(recipientPubHex: string, plaintext: Uint8Array): Uint8Array {
const recipientCurvePub = sodium.crypto_sign_ed25519_pk_to_curve25519(sodium.from_hex(recipientPubHex));
return sodium.crypto_box_seal(plaintext, recipientCurvePub);
}
openSealed(sealed: Uint8Array): Uint8Array | null {
try {
return sodium.crypto_box_seal_open(sealed, this.curvePub, this.curvePriv);
} catch {
return null;
}
}
}
export async function ready(): Promise<void> {
await sodium.ready;
}
// Symmetric payload encryption for a vault's contents.
export function generateSymmetricKey(): Uint8Array {
return sodium.crypto_secretbox_keygen();
}
export function secretboxEncrypt(key: Uint8Array, plaintext: Uint8Array): { nonce: Uint8Array; ciphertext: Uint8Array } {
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
return { nonce, ciphertext };
}
export function secretboxDecrypt(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array): Uint8Array {
return sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
}
export const enc = {
toHex: (b: Uint8Array) => sodium.to_hex(b),
fromHex: (s: string) => sodium.from_hex(s),
toBase64: (b: Uint8Array) => sodium.to_base64(b, sodium.base64_variants.ORIGINAL),
fromBase64: (s: string) => sodium.from_base64(s, sodium.base64_variants.ORIGINAL),
toUtf8: (b: Uint8Array) => sodium.to_string(b),
fromUtf8: (s: string) => sodium.from_string(s),
};
function sha256Hex(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');
}
// Canonical string a signed request's signature covers: binds method,
// path, a body hash, and a timestamp (replay window) together so a
// captured signature can't be replayed against a different request or
// after the timestamp window expires — a fixed, explicit byte layout
// rather than "sign whatever the body happens to be." Uses Node's built-in
// hash rather than libsodium's crypto_hash (which is SHA-512, not
// SHA-256, and not what's wanted here) — no reason to route a plain
// content hash through libsodium.
export function signingString(method: string, path: string, timestamp: number, body: Uint8Array): string {
const bodyHash = sha256Hex(body);
return `${method.toUpperCase()}\n${path}\n${timestamp}\n${bodyHash}`;
}

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}