Files
keep/IMPLEMENTATION.md
Fredrik Johansson 2c34562c7f 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 <noreply@anthropic.com>
2026-07-12 19:02:08 +02:00

12 KiB

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):

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 <vault> 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 <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

# admin operations — see below for how these are authenticated
keep recipient add --label "..." --pubkey <hex>
keep recipient list
keep recipient remove <recipient_id>
keep grant <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

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:

#!/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.