Implement announce/watch: a deploy signal that reverses CI's reach
All checks were successful
Docker / build-and-push (push) Successful in 1m58s

CI announces a new image tag to keep (any grant is enough, read-only
included); each deploy target polls for it locally instead of CI
holding standing SSH access to production. keep stays a pure relay —
the tag lives outside the encrypted vault payload in its own table,
and keep never executes anything itself.

Adds vault_announcements, POST/GET /vaults/:key/announce, and the
`keep announce`/`keep watch` CLI commands, per
PROPOSAL-announce-and-agent.md (now marked implemented). Verified live:
a read-only identity announcing successfully and being logged, watch
picking up the tag on its first poll, and watch exiting nonzero for an
unknown/ungranted vault.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-07-12 21:12:55 +02:00
parent 674e408f09
commit 58d5d787dd
7 changed files with 225 additions and 11 deletions

View File

@@ -183,6 +183,8 @@ keep push <vault> [--file .env] [--purge] -- encrypt + upload; requires a WRITE
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
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
# admin operations — gated by ADMIN_PASSWORD, see below
keep recipient add --label "..." --pubkey <hex>
@@ -215,6 +217,75 @@ 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