commit 2c34562c7ff965f49c0e19733df62cde2742d591 Author: Fredrik Johansson Date: Sun Jul 12 19:02:08 2026 +0200 Initial design: self-hosted E2E encrypted secrets sync README covers the pitch and how this differs from Vault/Doppler (too heavy) and a static age/sops-encrypted file in git (no live rotation, no access log). IMPLEMENTATION covers the multi-recipient key-wrapping scheme (age/sops-style, adapted to libsodium primitives already used in flit/waste-go), the flat vault/recipient/grant/access-log schema, rotation and revocation semantics (including the standard "revoke doesn't retroactively unread already-decrypted secrets" caveat), CLI surface, and deploy-script integration. Motivated by a real, already-felt annoyance: every repo in this project family (goonk, wisp, npm-statuspage, flit, waste-go) has its own hand-copied .env today, with no rotation story and no record of which machine has which secret. No code yet — design stage. Admin auth mechanism flagged as needing a decision before implementation starts. Co-Authored-By: Claude Sonnet 5 diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..18050b2 --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,247 @@ +# keep — implementation notes + +Design-stage document. Nothing here is built yet; this is the spec to +build against, not a description of existing code. + +## Threat model + +- The server operator can be assumed hostile-but-honest, same posture as + `wisp`: they might try to read stored secrets, but the design should + make that cryptographically impossible, not just policy-forbidden. +- The server **can** see: which vaults exist, which recipients exist, + which recipients are granted access to which vaults, payload size, and + access timestamps (who pulled/pushed what, when). It **cannot** see: + secret values, the symmetric key that encrypts any payload, or any + recipient's private key. +- **Not defended against**: a compromised recipient machine. If a + deploy box with legitimate access to a vault is compromised, the + attacker gets everything that machine's identity can decrypt — same as + any secrets manager, self-hosted or not. Revoking that recipient stops + *future* pulls; it does not retroactively invalidate secrets already + decrypted and sitting in that machine's memory/disk/logs. **The fix for + a compromised recipient is always rotating the underlying secrets + (`keep push` with new values), not just revoking the recipient.** This + is a standard, correct caveat that applies to every system built this + way (age, sops, Vault, all of them) — worth stating explicitly because + it's the single most common misunderstanding of what "revoke" buys you. +- **Not defended against**: a recipient with legitimate access + deliberately exfiltrating secrets they're authorized to read. Same + out-of-scope boundary wisp draws around a recipient re-sharing a + decrypted file — access control stops unauthorized reads, not + authorized-then-malicious ones. + +## Crypto scheme + +Per-vault, adapting the `age`/`sops`/PGP multi-recipient pattern (see +README's "Core model") to libsodium primitives already used elsewhere in +this project family: + +- **Payload encryption**: `crypto_secretbox` (XSalsa20-Poly1305), same + primitive `wisp` uses for its blobs. A fresh random 256-bit symmetric + key per push. +- **Key wrapping**: for each recipient granted access, the vault's + symmetric key is sealed to that recipient's X25519 public key via + `crypto_box` (or `crypto_box_seal` if the pusher's own identity + shouldn't need to be verifiable by the server — anonymous sealing, matches + how `flit`'s `Identity.seal()` already works, since the server has no + business knowing which recipient's key was used to encrypt which + wrapped copy beyond the recipient it's addressed to). +- **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 + box-sealing above. One identity type, reused for both signing (proving + who's pushing/pulling, for the access log) and encryption (wrapping), + exactly like the existing projects already do. +- **Push authentication**: a push should be signed by the pusher's + Ed25519 key so the access log records a real identity, not just "someone + 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 + requires a separate "can write" grant distinct from "can read," is an + open question below. + +## Data model + +Flat schema, matching the minimalism already established in `wisp` and +`npm-statuspage` (no unnecessary normalization — a vault is identified by +its own key string, not a foreign-keyed `projects`/`environments` pair): + +```sql +CREATE TABLE IF NOT EXISTS vaults ( + vault_key TEXT PRIMARY KEY, -- e.g. "wisp/production" — free-form, app-chosen + ciphertext BLOB NOT NULL, -- crypto_secretbox(payload, vault_symmetric_key) + nonce BLOB NOT NULL, + updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL -- recipient_id of whoever last pushed +); + +CREATE TABLE IF NOT EXISTS recipients ( + recipient_id TEXT PRIMARY KEY, -- short label-derived slug, e.g. "wisp-prod-deploy" + label TEXT NOT NULL, -- human-readable, e.g. "wisp production deploy key" + public_key TEXT NOT NULL, -- hex Ed25519 pubkey + created_at INTEGER NOT NULL +); + +-- The multi-recipient key-wrapping table. One row per (vault, recipient) +-- pair that currently has access. Deleting a row IS the revocation — +-- no separate "enabled" flag needed. +CREATE TABLE IF NOT EXISTS vault_grants ( + vault_key TEXT NOT NULL REFERENCES vaults(vault_key), + recipient_id TEXT NOT NULL REFERENCES recipients(recipient_id), + wrapped_key BLOB NOT NULL, -- crypto_box_seal(vault_symmetric_key, recipient_pubkey) + granted_at INTEGER NOT NULL, + PRIMARY KEY (vault_key, recipient_id) +); + +-- Access log — metadata only, exactly what the threat model says the +-- server is allowed to know. +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, -- 'pull' | 'push' + accessed_at INTEGER NOT NULL +); +``` + +A vault with zero rows in `vault_grants` is unreadable by anyone — +including, notably, whoever pushed it, unless they granted themselves +access first. This is intentional: creating a vault and granting access +to it are separate steps, so a vault can't accidentally be pushed +"open" to nobody or to a stale recipient list left over from a template. + +## Rotation + +`keep push ` with new secret values: + +1. Generate a fresh random symmetric key. +2. Encrypt the new payload under it. +3. Look up every current row in `vault_grants` for this vault. +4. Re-wrap the new symmetric key for every one of those recipients' + public keys (already have them via `recipients.public_key`). +5. Replace `vaults.ciphertext`/`nonce` and all `vault_grants.wrapped_key` + rows for this vault, atomically (single transaction). + +The old symmetric key and old wrapped copies are simply gone — +overwritten, not versioned, in v1 (see Open Questions on whether +version history is worth adding). + +## Revocation + +Deleting one `vault_grants` row (`vault_key`, `recipient_id`) removes +that recipient's ability to `pull` — the very next pull attempt with +their identity finds no wrapped-key row and is rejected. No re-encryption +of the payload needed; every other recipient's access is untouched. + +As stated in the threat model: this stops *future* pulls only. If +revocation is happening because a machine may have been compromised, +follow it with `keep push` to actually rotate the secret values, not just +the grant. + +## CLI surface (sketch) + +``` +keep identity init -- generate + persist a local Ed25519 keypair (~/.keep/identity) +keep identity show -- print this machine's public key, for registering as a recipient + +keep push [--file .env] -- encrypt + upload; requires this identity to already be a grantee +keep pull [--format=env|json] -- fetch + decrypt; requires a grant for this identity + +# admin operations — see below for how these are authenticated +keep recipient add --label "..." --pubkey +keep recipient list +keep recipient remove +keep grant +keep revoke +keep log -- tail the access log for one vault +``` + +### Admin operations need their own authorization story + +Adding/removing recipients and granting/revoking vault access is a +different trust level than pushing/pulling a specific vault you already +have a grant for. Options, in rough order of how much new infrastructure +they cost: + +1. **Bootstrap admin identity**: the first recipient ever registered is + implicitly the admin; admin operations require a signature from that + identity. Cheapest — no new credential type — but doesn't scale past + "it's just me" without more thought. +2. **Separate `ADMIN_PASSWORD`**, same pattern as `wisp`'s proposed + invite-links admin gate and `npm-statuspage`'s admin panel (both + already built/proposed as of this writing) — a shared password + distinct from any recipient identity, gating a small admin HTTP + 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 + alongside read access, so admin authority is scoped per-vault instead + of global. More correct, more to build; probably not v1. + +Leaning toward (2) for v1 — it's proven twice already elsewhere in this +project family, and a global admin password is an acceptable trust model +for "one person or small team running their own homelab," which is the +actual scale this is being built for. + +## Deploy integration + +The actual motivating use case — a deploy script pulling secrets instead +of assuming a hand-copied `.env` already exists on the target machine: + +```bash +#!/usr/bin/env bash +# top of an existing deploy-*.sh script, e.g. waste-go's deploy-daemon.sh +set -euo pipefail + +keep pull wisp/production --format=env > .env +# ...rest of the existing deploy script, unchanged, now consuming a +# freshly-pulled .env instead of one that was manually placed there +``` + +Requires the deploy machine to have a `keep` identity already registered +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 +public key access to whichever vaults it needs). + +## Security considerations + +- Every point already covered under Threat Model applies; this section + covers implementation-level details that aren't strictly part of the + threat model but matter for correctness. +- **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 + `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 + single SQLite transaction, same discipline as wisp's atomic confirm-token + consumption. +- **The access log is genuinely useful, not just decorative** — the + README's whole pitch versus a static `age`-encrypted file in git is + "you can tell who actually pulled what, when." Make sure `pull` + failures (wrong/no grant) are *also* logged, not just successes — a + spike of failed pulls from an unexpected identity is exactly the kind + of signal this feature exists to surface. +- **Recipient private keys are the actual crown jewels**, same as + `flit`/`waste-go`'s existing identity model. `keep identity init` + should follow the same local-storage discipline those already use + (`~/.flit/`-style, not committed anywhere, not transmitted anywhere + except the public half). + +## Open questions + +- **Version history**: keep only the latest payload per vault (simplest, + matches the "this predates rollback needs" scope), or retain N previous + versions for `keep pull --version=N` rollback? Leaning toward: skip for + v1, revisit if a real rollback need shows up. +- **Push authorization**: does having a *read* grant (`vault_grants` row) + imply push rights too, or is push a separate capability? Leaning toward: + read implies write for v1 (simpler, matches "small team, own homelab" + scale) — a `can_write` flag on `vault_grants` is the natural extension + if that turns out to be wrong. +- **Per-secret-key granularity**: v1 treats a vault as one opaque + `.env`-shaped blob (matches the actual current pain point: whole files + get copied around, not individual keys). If a use case emerges for + "grant access to just the DB password, not the whole bundle," that's a + genuinely different data model (per-key rows, each separately wrapped) + — don't half-build it speculatively now. +- **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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..29bedd9 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# 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, +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: + +- 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 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. + +## 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 — same identity model already + built twice over in `flit` and `waste-go`. +2. A vault (`wisp/production`, `goonk/production`, 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. + +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). + +## Status + +Design only. No code yet.