Files
keep/IMPLEMENTATION.md

409 lines
21 KiB
Markdown
Raw Normal View History

# keep — implementation notes
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>
2026-07-12 19:38:24 +02:00
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
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>
2026-07-12 19:38:24 +02:00
- 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
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>
2026-07-12 19:38:24 +02:00
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
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>
2026-07-12 19:38:24 +02:00
README's "Core model") to libsodium primitives:
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>
2026-07-12 19:38:24 +02:00
- **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
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>
2026-07-12 19:38:24 +02:00
`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
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>
2026-07-12 19:38:24 +02:00
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 having a read grant should imply
write too was an open question at design time — resolved as *no*; see
"Resolved design decisions" below.
## Data model
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>
2026-07-12 19:38:24 +02:00
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,
-- Exactly one previous version, retained as a rollback safety net.
-- NULL if there is none, or the last push explicitly purged it.
prev_ciphertext BLOB,
prev_nonce BLOB,
updated_at INTEGER NOT NULL,
updated_by TEXT NOT NULL -- recipient_id of whoever last pushed
);
CREATE TABLE IF NOT EXISTS recipients (
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>
2026-07-12 19:38:24 +02:00
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)
can_write INTEGER NOT NULL DEFAULT 1, -- read-only grants can pull but not push/grant
granted_at INTEGER NOT NULL,
PRIMARY KEY (vault_key, recipient_id)
);
-- Mirrors vault_grants for the one retained previous version — wrapped
-- keys as they stood at the time of the push that superseded them, so a
-- recipient who had access then can still unwrap it via --previous even
-- if their current grant has since changed.
CREATE TABLE IF NOT EXISTS vault_grants_previous (
vault_key TEXT NOT NULL,
recipient_id TEXT NOT NULL,
wrapped_key TEXT 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' | 'grant' | 'revoke'
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, run by a recipient holding a
*write* grant (or by anyone, for a brand-new vault key — see "Push
authorization" below):
1. Generate a fresh random symmetric key.
2. Encrypt the new payload under it.
3. Fetch the current `vault_grants` rows for this vault (via `GET
/vaults/:key/recipients`, itself grant-gated) — each recipient's
public key and current `can_write` flag.
4. Re-wrap the new symmetric key for every one of those recipients'
public keys, preserving each one's `can_write` flag as-is — a
rotation never silently upgrades or downgrades anyone's write access.
5. Unless `--purge`: copy the *outgoing* `vaults.ciphertext`/`nonce` into
`prev_ciphertext`/`prev_nonce`, and copy the outgoing `vault_grants`
rows into `vault_grants_previous`, before overwriting them. With
`--purge`: skip that copy, and also clear out whatever previous
version already existed — a purge means nothing from before this push
survives, not just that this push doesn't add to a stack.
6. Replace `vaults.ciphertext`/`nonce` and all `vault_grants` rows for
this vault, atomically (single transaction covering steps 5 and 6).
## Rollback and purge
`keep pull <vault> --previous` fetches `prev_ciphertext`/`prev_nonce`
plus the caller's row in `vault_grants_previous`, if any — a plain 404 if
there is no previous version, or the caller didn't have access to it.
This is a *safety net* for "I pushed a typo'd value," not an audit trail:
exactly one previous version is kept, not a stack.
`keep push <vault> --purge` is the compromise-response path: it rotates
the vault the same as a normal push, except it never retains the
outgoing version as "previous" and actively deletes whatever previous
version already existed. The distinction matters because the entire
point of rotating in response to a leak is that the *old* value stops
being retrievable by anyone — a routine push's one-step rollback safety
net would directly undermine that if it applied here too.
## 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.
(`vault_grants_previous` rows are left alone by a plain revoke — access
to the previous version isn't retroactively pulled just because current
access was revoked; a `--purge` push is what actually removes that.)
As stated in the threat model: revocation alone stops *future* pulls
only. If revocation is happening because a machine may have been
compromised, follow it with `keep push <vault> --purge` to actually
rotate the secret values and ensure the old ones aren't retrievable via
`--previous` either — not just remove the grant.
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>
2026-07-12 19:38:24 +02:00
## CLI surface
```
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>
2026-07-12 19:38:24 +02:00
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
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>
2026-07-12 19:38:24 +02:00
keep identity set-id <recipient-id> -- record the id an admin assigned after registering this identity
keep push <vault> [--file .env] [--purge] -- encrypt + upload; requires a WRITE grant on an existing vault
keep pull <vault> [--format env|json] [--out <path>] [--previous] -- fetch + decrypt; requires a grant
keep grant <vault> <recipient-id> [--read-only] -- add a recipient to a vault you have WRITE access to
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>
2026-07-12 19:38:24 +02:00
keep log <vault> -- tail the access log for one vault
keep announce <vault> --tag <tag> -- record a deploy tag; requires ANY grant on the vault (read-only is enough)
keep watch <vault> [--interval 60] -- poll for a new announced tag; exits nonzero if the vault doesn't exist / no grant
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>
2026-07-12 19:38:24 +02:00
# admin operations — gated by ADMIN_PASSWORD, see below
keep recipient add --label "..." --pubkey <hex>
keep recipient list
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>
2026-07-12 19:38:24 +02:00
keep recipient remove <recipient-id>
keep revoke <vault> <recipient-id>
keep overview -- every vault + every grant in one screen (GET /api/admin/overview)
```
### Admin operations need their own authorization story
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>
2026-07-12 19:38:24 +02:00
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.
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>
2026-07-12 19:38:24 +02:00
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
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>
2026-07-12 19:38:24 +02:00
of global. More correct, more to build; not v1.
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>
2026-07-12 19:38:24 +02:00
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.
## Announce/watch — a deploy signal, not a secret
CI producing a fresh image on every push still left deployment as a
manual step, and the obvious fix — CI SSHes into the VPS and redeploys —
was rejected: it means a standing SSH credential with production-deploy
rights sits in CI secrets, reachable by whatever's executing on a shared
runner across every project it builds. A compromised build step in one
project could then reach every other project's deploy target, which is
a real regression from `keep`'s own "read no longer implies write"
posture (see above).
The fix that doesn't reintroduce that problem: reverse the direction.
CI never reaches into a VPS — it tells `keep` "there's a new image for
this project" (`keep announce <vault> --tag <tag>`), and each VPS, which
already has to run *something* locally to redeploy itself, polls `keep`
for that signal (`keep watch <vault>`) and acts on it locally, using an
identity and grant it already legitimately holds. `keep`'s server never
executes anything remotely — it stays a pure relay, same posture as
everything else it does.
This was deliberately not built as "`keep` executes the redeploy
itself," even though `keep` typically runs on the same box as its
deploy targets and that would skip the polling indirection entirely.
Doing so would trade away the property that's been the throughline of
`keep`'s whole design: today, a fully compromised `keep` server — not
just a compromised client identity — can't do anything worse than leak
ciphertext and metadata, because the server never holds a decryption
key. The moment `keep` can also execute a command on `announce`, an
ordinary web app bug (not even a cryptographic break) becomes "attacker
triggers arbitrary redeploys" — a new class of bad outcome, not a bigger
version of an existing one.
An announced tag is stored outside the encrypted vault payload
(`vault_announcements`, one row per vault — latest known state, not a
history, same shape as `vaults.prev_ciphertext`) since an image tag
isn't a secret and doesn't belong inside a secrets blob alongside real
credentials. `POST /vaults/:key/announce` only requires *a* grant, not a
write grant — `hasGrant`, not `hasWriteGrant` — since announcing isn't a
mutation of a vault's secret content, just a signal; a CI identity for a
project can stay read-only even with this feature in play. `keep watch`
is deliberately dumb polling, not a websocket or long-poll — matches the
pattern already used elsewhere in this project family, and a 60-second
detection lag for a deploy is an acceptable tradeoff against the
complexity of push-based notification.
Two things worth stating plainly about what this does and doesn't
change:
- The tag itself is now server-visible, where previously not even the
fact that a new image existed was something `keep` needed to know.
Accepted tradeoff — a git short-SHA isn't sensitive, and the feature
only works if `keep` can see it.
- A compromised read-only CI identity could announce a *false* tag,
pointing a deploy target at an attacker-chosen image. `keep` never
pulls or runs that image itself, only relays the string — the actual
`docker compose pull` on the deploy target still pulls from whatever
registry it's configured for, using the tag it was told. This is
equivalent in severity to that CI identity's build step being
compromised in the first place (it could already push a bad image
under a legitimate tag), not a new capability.
A bulk multi-project deploy script (looping over a fixed project list,
`docker compose up -d` per project) adopts this by checking for a
per-project `.keep-vault` marker file before calling `keep pull`; a
missing marker falls through to exactly what the script did before
`keep` existed — never a hard failure, since not every project will be
bootstrapped onto `keep` at the same time. `scripts/deploy.sh` in this
repo is the single-project version of the same pattern.
## 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
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>
2026-07-12 19:38:24 +02:00
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
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>
2026-07-12 19:38:24 +02:00
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
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>
2026-07-12 19:38:24 +02:00
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
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>
2026-07-12 19:38:24 +02:00
"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
**Initial implementation**, 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.
**Resolved-decisions pass**, three independent identities: a read-only
grant correctly blocked from both `push` and `grant`; write access and
read-only status both correctly preserved across a rotation (confirmed
by the "wrapped for N recipients" count and by the read-only recipient
still being able to pull but not push afterward); `--previous` returning
the prior version for a recipient who had access at that time; a
`--purge` push confirmed to make the previously-current version
unavailable via `--previous` for every recipient, not just the pusher;
re-verified against a fresh Docker build.
Two real implementation bugs caught during this process, not just
written up:
- 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.
- The CLI's `--previous` flag initially signed a path that included its
own query string (`?previous=1`), but the server verifies against
`req.originalUrl` with the query string stripped — every `--previous`
request would have failed signature verification. Fixed by splitting
the signed path from the request URL in `signedFetch`, signing only
the former.
Resolve open design questions: write-scoped grants, rollback, purge Per-question resolution, per best practice rather than deferral: - Push authorization: closed the least-privilege gap where read implied write. vault_grants gained can_write (default true, so nothing existing changes); enforced on push and grant (granting others is itself a mutation of vault membership, so it needs write too, not just read). 'keep grant --read-only' creates a read-only grant. - Version history: not full history (conflates "undo a typo'd push" with "this leaked, stop retaining it" into one mechanism). Retains exactly one previous version as a rollback safety net (vaults.prev_ciphertext/prev_nonce + a vault_grants_previous mirror table so recipients can unwrap it), plus 'keep push --purge' for compromise-driven rotations that explicitly skips retention and wipes any existing previous version too. - Per-secret-key granularity: resolved by NOT building it — documented the escape hatch (split into more vaults) instead of adding partial- decrypt complexity for a problem the existing primitive solves. One more real bug caught during verification: the CLI's --previous flag initially signed a path including its query string, but the server verifies against req.originalUrl with the query stripped — a mismatch that would have made every --previous request fail signature verification. Fixed by splitting the signed path from the request URL in signedFetch, signing only the former. Verified end-to-end with three independent identities: read-only grant correctly blocked from push and from granting others, write access and read-only status both preserved correctly across a rotation, previous- version pull working for a routine push and correctly unavailable to every recipient after a purge push. Also re-verified against a fresh Docker build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:50:08 +02:00
## 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.