# keep — implementation notes Design-stage document, now implemented — kept as the record of *why* the design looks the way it does, not just a description of the code. ## Threat model - The server operator can be assumed hostile-but-honest: 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. Access control stops unauthorized reads, not authorized-then-malicious ones — the same boundary any system like this draws. ## Crypto scheme Per-vault, adapting the `age`/`sops`/PGP multi-recipient pattern (see README's "Core model") to libsodium primitives: - **Payload encryption**: `crypto_secretbox` (XSalsa20-Poly1305). 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_seal` — anonymous sealing, since the server has no business knowing which specific identity wrapped a copy beyond the recipient it's addressed to. - **Identity**: Ed25519 signing keypair per client, with the standard sign-to-curve25519 conversion (`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). - **Push authentication**: every push is 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," was an open question at design time — resolved as "read implies write" for v1 (see Open Questions). ## Data model Flat schema — 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. "myapp/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. "prod-deploy-a1b2c3" label TEXT NOT NULL, -- human-readable, e.g. "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 ``` 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 set-id -- record the id an admin assigned after registering this identity keep push [--file .env] -- encrypt + upload; requires this identity to already be a grantee keep pull [--format env|json] [--out ] -- fetch + decrypt; requires a grant for this identity keep grant -- add a new recipient to a vault you already have access to keep log -- tail the access log for one vault # admin operations — gated by ADMIN_PASSWORD, see below keep recipient add --label "..." --pubkey keep recipient list keep recipient remove keep revoke ``` ### Admin operations need their own authorization story Adding/removing recipients and revoking vault access is a different trust level than pushing/pulling a specific vault you already have a grant for. Options considered, 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`** — a shared password distinct from any recipient identity, gating a small admin HTTP surface. This is what got built: same shape as a password-gated admin panel, disabled entirely (503) rather than boot-failing when unset. 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; not v1. Went with (2) — 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 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 set -euo pipefail keep pull myapp/production --format env --out .env # ...rest of an 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). See [INTEGRATION.md](./INTEGRATION.md) for where this pattern does and doesn't fit an existing deploy setup. ## 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 — the read-grants-then-write-wrapped-keys sequence runs inside a single SQLite transaction. - **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." `pull` failures (wrong/no grant) are logged too, 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.** `keep identity init` persists the local keypair at `~/.keep/identity.json` (mode `0o600`, directory `0o700`) — not committed anywhere, not transmitted anywhere 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 - **Version history**: keep only the latest payload per vault (what got built), or retain N previous versions for `keep pull --version=N` rollback? Skipped 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? Went with "read implies write" for v1 — 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 pain point this was built for: whole files get copied around, not individual keys). If a use case emerges for "grant access to just one value, not the whole bundle," that's a genuinely different data model (per-key rows, each separately wrapped) — don't half-build it speculatively.