Fix documentation drift from the resolved-open-questions pass
IMPLEMENTATION.md hadn't been updated after the second implementation pass — the data model SQL was missing prev_ciphertext/prev_nonce, can_write, and the entire vault_grants_previous table; the Rotation section described the old "overwritten, not versioned" behavior (the opposite of what got built); the crypto scheme section still said "read implies write" while the Resolved design decisions section below it said the opposite; the CLI surface listing was missing --purge/--previous/--read-only; and "What was verified" only covered the first verification pass, not the read-only/rollback/purge one. README's Core model was missing any mention of the retention/purge behavior or read/write grant scoping. INTEGRATION.md now recommends --read-only for deploy identities specifically, now that the capability exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,11 +48,9 @@ README's "Core model") to libsodium primitives:
|
|||||||
who's pushing/pulling, for the access log) and encryption (wrapping).
|
who's pushing/pulling, for the access log) and encryption (wrapping).
|
||||||
- **Push authentication**: every push is signed by the pusher's Ed25519
|
- **Push authentication**: every push is signed by the pusher's Ed25519
|
||||||
key so the access log records a real identity, not just "someone with
|
key so the access log records a real identity, not just "someone with
|
||||||
write access to this vault." Whether *anyone* with any registered
|
write access to this vault." Whether having a read grant should imply
|
||||||
identity can push to a vault they're a recipient of, or whether push
|
write too was an open question at design time — resolved as *no*; see
|
||||||
requires a separate "can write" grant distinct from "can read," was an
|
"Resolved design decisions" below.
|
||||||
open question at design time — resolved as "read implies write" for v1
|
|
||||||
(see Open Questions).
|
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
|
|
||||||
@@ -64,6 +62,10 @@ CREATE TABLE IF NOT EXISTS vaults (
|
|||||||
vault_key TEXT PRIMARY KEY, -- e.g. "myapp/production" — free-form, app-chosen
|
vault_key TEXT PRIMARY KEY, -- e.g. "myapp/production" — free-form, app-chosen
|
||||||
ciphertext BLOB NOT NULL, -- crypto_secretbox(payload, vault_symmetric_key)
|
ciphertext BLOB NOT NULL, -- crypto_secretbox(payload, vault_symmetric_key)
|
||||||
nonce BLOB NOT NULL,
|
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_at INTEGER NOT NULL,
|
||||||
updated_by TEXT NOT NULL -- recipient_id of whoever last pushed
|
updated_by TEXT NOT NULL -- recipient_id of whoever last pushed
|
||||||
);
|
);
|
||||||
@@ -82,17 +84,29 @@ CREATE TABLE IF NOT EXISTS vault_grants (
|
|||||||
vault_key TEXT NOT NULL REFERENCES vaults(vault_key),
|
vault_key TEXT NOT NULL REFERENCES vaults(vault_key),
|
||||||
recipient_id TEXT NOT NULL REFERENCES recipients(recipient_id),
|
recipient_id TEXT NOT NULL REFERENCES recipients(recipient_id),
|
||||||
wrapped_key BLOB NOT NULL, -- crypto_box_seal(vault_symmetric_key, recipient_pubkey)
|
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,
|
granted_at INTEGER NOT NULL,
|
||||||
PRIMARY KEY (vault_key, recipient_id)
|
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
|
-- Access log — metadata only, exactly what the threat model says the
|
||||||
-- server is allowed to know.
|
-- server is allowed to know.
|
||||||
CREATE TABLE IF NOT EXISTS access_log (
|
CREATE TABLE IF NOT EXISTS access_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
vault_key TEXT NOT NULL,
|
vault_key TEXT NOT NULL,
|
||||||
recipient_id TEXT NOT NULL,
|
recipient_id TEXT NOT NULL,
|
||||||
action TEXT NOT NULL, -- 'pull' | 'push'
|
action TEXT NOT NULL, -- 'pull' | 'push' | 'grant' | 'revoke'
|
||||||
accessed_at INTEGER NOT NULL
|
accessed_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
@@ -105,19 +119,42 @@ to it are separate steps, so a vault can't accidentally be pushed
|
|||||||
|
|
||||||
## Rotation
|
## Rotation
|
||||||
|
|
||||||
`keep push <vault>` with new secret values:
|
`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.
|
1. Generate a fresh random symmetric key.
|
||||||
2. Encrypt the new payload under it.
|
2. Encrypt the new payload under it.
|
||||||
3. Look up every current row in `vault_grants` for this vault.
|
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'
|
4. Re-wrap the new symmetric key for every one of those recipients'
|
||||||
public keys (already have them via `recipients.public_key`).
|
public keys, preserving each one's `can_write` flag as-is — a
|
||||||
5. Replace `vaults.ciphertext`/`nonce` and all `vault_grants.wrapped_key`
|
rotation never silently upgrades or downgrades anyone's write access.
|
||||||
rows for this vault, atomically (single transaction).
|
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).
|
||||||
|
|
||||||
The old symmetric key and old wrapped copies are simply gone —
|
## Rollback and purge
|
||||||
overwritten, not versioned, in v1 (see Open Questions on whether
|
|
||||||
version history is worth adding).
|
`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
|
## Revocation
|
||||||
|
|
||||||
@@ -125,11 +162,15 @@ Deleting one `vault_grants` row (`vault_key`, `recipient_id`) removes
|
|||||||
that recipient's ability to `pull` — the very next pull attempt with
|
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
|
their identity finds no wrapped-key row and is rejected. No re-encryption
|
||||||
of the payload needed; every other recipient's access is untouched.
|
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: this stops *future* pulls only. If
|
As stated in the threat model: revocation alone stops *future* pulls
|
||||||
revocation is happening because a machine may have been compromised,
|
only. If revocation is happening because a machine may have been
|
||||||
follow it with `keep push` to actually rotate the secret values, not just
|
compromised, follow it with `keep push <vault> --purge` to actually
|
||||||
the grant.
|
rotate the secret values and ensure the old ones aren't retrievable via
|
||||||
|
`--previous` either — not just remove the grant.
|
||||||
|
|
||||||
## CLI surface
|
## CLI surface
|
||||||
|
|
||||||
@@ -138,9 +179,9 @@ keep identity init -- generate + persist a local Ed25519
|
|||||||
keep identity show -- print this machine's public key, for registering as a recipient
|
keep identity show -- print this machine's public key, for registering as a recipient
|
||||||
keep identity set-id <recipient-id> -- record the id an admin assigned after registering this identity
|
keep identity set-id <recipient-id> -- record the id an admin assigned after registering this identity
|
||||||
|
|
||||||
keep push <vault> [--file .env] -- encrypt + upload; requires this identity to already be a grantee
|
keep push <vault> [--file .env] [--purge] -- encrypt + upload; requires a WRITE grant on an existing vault
|
||||||
keep pull <vault> [--format env|json] [--out <path>] -- fetch + decrypt; requires a grant for this identity
|
keep pull <vault> [--format env|json] [--out <path>] [--previous] -- fetch + decrypt; requires a grant
|
||||||
keep grant <vault> <recipient-id> -- add a new recipient to a vault you already have access to
|
keep grant <vault> <recipient-id> [--read-only] -- add a recipient to a vault you have WRITE access to
|
||||||
keep log <vault> -- tail the access log for one vault
|
keep log <vault> -- tail the access log for one vault
|
||||||
|
|
||||||
# admin operations — gated by ADMIN_PASSWORD, see below
|
# admin operations — gated by ADMIN_PASSWORD, see below
|
||||||
@@ -217,22 +258,41 @@ doesn't fit an existing deploy setup.
|
|||||||
|
|
||||||
## What was verified end-to-end
|
## What was verified end-to-end
|
||||||
|
|
||||||
Two independent local identities against a real running server (and
|
**Initial implementation**, two independent local identities against a
|
||||||
separately against the built Docker image): registration, a push,
|
real running server (and separately against the built Docker image):
|
||||||
pulling as the pusher, pulling as an ungranted second identity (correctly
|
registration, a push, pulling as the pusher, pulling as an ungranted
|
||||||
rejected), granting the second identity access without re-pushing,
|
second identity (correctly rejected), granting the second identity
|
||||||
pulling as the now-granted second identity (correctly decrypts the same
|
access without re-pushing, pulling as the now-granted second identity
|
||||||
underlying secret via its own sealed key copy), admin revocation, a
|
(correctly decrypts the same underlying secret via its own sealed key
|
||||||
subsequent rotation confirming the revoked identity is excluded from the
|
copy), admin revocation, a subsequent rotation confirming the revoked
|
||||||
new wrap set, and rejection of both missing and malformed signed-request
|
identity is excluded from the new wrap set, and rejection of both
|
||||||
auth.
|
missing and malformed signed-request auth.
|
||||||
|
|
||||||
One real implementation bug caught during this process, worth recording:
|
**Resolved-decisions pass**, three independent identities: a read-only
|
||||||
Express's `req.path` inside a sub-router is relative to that router's
|
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`),
|
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
|
which would have silently mismatched a client that signs the full
|
||||||
path. Fixed by verifying against `req.originalUrl` (path portion only)
|
request path. Fixed by verifying against `req.originalUrl` (path
|
||||||
instead, which stays consistent regardless of router nesting.
|
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.
|
||||||
|
|
||||||
## Resolved design decisions
|
## Resolved design decisions
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ printed public key to whoever runs `keep recipient add`, then
|
|||||||
deploy from that machine just works — no more "wait, what's the DB
|
deploy from that machine just works — no more "wait, what's the DB
|
||||||
password again" when setting up a new box.
|
password again" when setting up a new box.
|
||||||
|
|
||||||
|
A deploy identity should almost always be granted **read-only** access
|
||||||
|
(`keep grant <vault> <deploy-recipient-id> --read-only`) — it only ever
|
||||||
|
needs to `pull`, and a read-only grant means a compromised deploy box
|
||||||
|
can't also rotate the vault or add itself more recipients.
|
||||||
|
|
||||||
## Pattern: docker-compose reading a local `.env`
|
## Pattern: docker-compose reading a local `.env`
|
||||||
|
|
||||||
A common shape — `docker compose up -d` reads `environment:` values from
|
A common shape — `docker compose up -d` reads `environment:` values from
|
||||||
|
|||||||
@@ -65,7 +65,13 @@ recipient's public key — into a live service instead of a static file:
|
|||||||
the payload and every other recipient's access are untouched.
|
the payload and every other recipient's access are untouched.
|
||||||
6. Rotating a secret is `keep push <vault>` again — a new random
|
6. Rotating a secret is `keep push <vault>` again — a new random
|
||||||
symmetric key, re-wrapped for every currently-granted recipient. Cheap,
|
symmetric key, re-wrapped for every currently-granted recipient. Cheap,
|
||||||
since the recipient list rarely changes.
|
since the recipient list rarely changes. By default the outgoing
|
||||||
|
version is kept as a one-step rollback safety net (`keep pull
|
||||||
|
--previous`); `keep push --purge` skips that for compromise-driven
|
||||||
|
rotations where the old value should stop being retrievable, full stop.
|
||||||
|
7. Grants distinguish read from write — a recipient can be given
|
||||||
|
read-only access (`keep grant --read-only`), which can pull a vault
|
||||||
|
but can't push to it or grant others access.
|
||||||
|
|
||||||
Full design — data model, the atomic-grant/revoke details, and what this
|
Full design — data model, the atomic-grant/revoke details, and what this
|
||||||
explicitly does and doesn't protect against — is in
|
explicitly does and doesn't protect against — is in
|
||||||
|
|||||||
Reference in New Issue
Block a user