# 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. ## Resolved design decisions These were open questions at initial implementation; resolved as follows. ### Version history: one previous version, not full history Full N-version history (what Vault/AWS Secrets Manager do) conflates two different needs that deserve different treatment: "I pushed a typo'd value, let me roll back" wants a safety net; "this credential leaked" wants the old value to stop existing anywhere retrievable, which a kept version directly undermines. Resolution: retain exactly the immediately previous payload as a rollback safety net (`keep pull --previous`), plus an explicit hard-rotate mode (`keep push --purge`) that skips retention entirely for compromise-driven rotations. Not full history — the two real use cases are served without the complexity (or the confused security story) of an unbounded log of past secret values. ### Push authorization: read no longer implies write The original "read implies write" v1 simplification was a real least-privilege gap: an automated deploy identity that only ever needs to *pull* a vault also had the power to overwrite it — a compromised read-only consumer could push garbage values or silently exclude other recipients from the next rotation. Fixed rather than deferred, since the cost of closing it is low and the cost of retrofitting it later (once grants without write intent already exist) is higher: `vault_grants` gained a `can_write` column (`1` by default, so nothing existing changes behavior), enforced in the push handler, with `keep grant --read-only` to create a read-only grant explicitly. ### Per-secret-key granularity: resolved by not building it The right answer for "different recipients need different subsets of secrets" is splitting into more vaults (`myapp/db`, `myapp/api`, each with their own recipient list), not partial-access ACLs within one blob. A vault staying the permission boundary keeps the mental model simple and auditable; per-key wrapping would solve the same problem with materially more complexity for no real gain over just making more vaults. No code change — this documents the escape hatch instead of leaving a dangling TODO.