diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 106ef92..ddc74ed 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -183,6 +183,8 @@ keep push [--file .env] [--purge] -- encrypt + upload; requires a WRITE keep pull [--format env|json] [--out ] [--previous] -- fetch + decrypt; requires a grant keep grant [--read-only] -- add a recipient to a vault you have WRITE access to keep log -- tail the access log for one vault +keep announce --tag -- record a deploy tag; requires ANY grant on the vault (read-only is enough) +keep watch [--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 @@ -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 --tag `), and each VPS, which +already has to run *something* locally to redeploy itself, polls `keep` +for that signal (`keep watch `) 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 diff --git a/PROPOSAL-announce-and-agent.md b/PROPOSAL-announce-and-agent.md index 99f8c7c..fb52bd8 100644 --- a/PROPOSAL-announce-and-agent.md +++ b/PROPOSAL-announce-and-agent.md @@ -1,8 +1,15 @@ # Proposal: announce + a self-deploying agent, with a no-`keep` fallback -**Status:** proposed, not built. Self-contained — written so a fresh agent -with no prior conversation context can pick this up and implement it -without anything explained first. +**Status:** implemented (§1–§3, §6 shape). `announce`/`watch`, the +`vault_announcements` table, and the API routes described below are +built — see [IMPLEMENTATION.md](./IMPLEMENTATION.md)'s "Announce/watch" +section for the as-built version of the design rationale, and the CLI +surface list there for the exact commands. §4 (patching the real bulk +deploy script) and §6 (the CI workflow step) are per-project changes +made outside this repo, on the host and in each project's own +`.gitea/workflows/docker.yml` — not something this repo's code can +contain. This file is kept as the original design record, superseded by +IMPLEMENTATION.md as the source of truth for what's actually built. ## Context (read this first) diff --git a/README.md b/README.md index f18246e..767c5f7 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,29 @@ Pushing to an existing vault requires a *write* grant — a read-only recipient can pull but can't push or grant others access. A brand-new vault's first push is always read-write for the pusher. +## Announcing deploys + +`keep` can also relay a small "there's a new image" signal alongside a +vault, so CI never needs standing SSH access to a deploy target — CI +announces a tag, and whatever already redeploys that box locally polls +for it instead of being reached into from outside: + +```bash +# CI, after a successful build/push — any grant is enough, read-only included: +keep announce myapp/production --tag + +# The deploy target polls for a change and prints it: +keep watch myapp/production --interval 60 +``` + +An announced tag isn't a secret — it lives outside the encrypted vault +payload, in its own small table, and is visible to the server (same as +vault names and access timestamps already are). `keep` never pulls or +runs the announced image itself; it only relays the string. See +[IMPLEMENTATION.md](./IMPLEMENTATION.md) for how this fits into a bulk +deploy script with a no-`keep` fallback for projects not yet bootstrapped +onto it. + ## Docker ```bash @@ -177,12 +200,16 @@ CLI-side: `KEEP_SERVER_URL` (default `http://localhost:3050`), Implemented: identity, push/pull/grant/revoke/log, read/write grant scoping, one-step rollback with an explicit purge mode for -compromise-driven rotations, admin recipient management, Docker deploy. -Verified end-to-end — two and three independent identities, read-only -grants correctly blocked from push/grant, grants preserved across -rotation, previous-version pull working and correctly wiped by -`--purge` for every recipient, malformed-auth rejection — against both -a local server and the built Docker image. See +compromise-driven rotations, admin recipient management, an admin +cross-vault `overview`, deploy-signal `announce`/`watch`, a +`scripts/deploy.sh` wrapper, and Docker deploy. Verified end-to-end — +two and three independent identities, read-only grants correctly +blocked from push/grant, grants preserved across rotation, +previous-version pull working and correctly wiped by `--purge` for +every recipient, a read-only identity successfully announcing while +still being logged, `watch` picking up an announced tag and exiting +nonzero for an unknown/ungranted vault, malformed-auth rejection — +against both a local server and the built Docker image. See [IMPLEMENTATION.md](./IMPLEMENTATION.md) for the full design and its resolved decisions (per-secret-key granularity deliberately not built — use multiple vaults instead). diff --git a/src/cli/commands/vault.ts b/src/cli/commands/vault.ts index 3ed6514..8a0ab11 100644 --- a/src/cli/commands/vault.ts +++ b/src/cli/commands/vault.ts @@ -114,3 +114,35 @@ export async function vaultLog(vaultKey: string): Promise { console.log(`${new Date(e.accessed_at * 1000).toISOString()} ${e.action.padEnd(6)} ${e.recipient_id}`); } } + +export async function vaultAnnounce(vaultKey: string, tag: string): Promise { + await ready(); + const { identity, recipientId } = await requireIdentityAndRecipientId(); + await expectOk(await signedFetch(identity, recipientId, 'POST', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`, { tag })); + console.log(`announced '${vaultKey}' @ ${tag}`); +} + +async function fetchAnnouncement(vaultKey: string): Promise<{ tag: string; announcedAt: number; announcedBy: string } | null> { + const { identity, recipientId } = await requireIdentityAndRecipientId(); + const res = await signedFetch(identity, recipientId, 'GET', `/api/vaults/${encodeURIComponent(vaultKey)}/announce`); + if (res.status === 404) return null; + return await expectOk(res); +} + +// Deliberately dumb — plain polling, not a websocket or long-poll. A +// 60s detection lag for a deploy signal is fine; don't build push-based +// notification for this. +export async function vaultWatch(vaultKey: string, intervalSeconds: number): Promise { + await ready(); + console.log(`watching '${vaultKey}' every ${intervalSeconds}s (ctrl-c to stop)...`); + + let lastTag: string | null = null; + for (;;) { + const announcement = await fetchAnnouncement(vaultKey); + if (announcement && announcement.tag !== lastTag) { + lastTag = announcement.tag; + console.log(`${new Date(announcement.announcedAt * 1000).toISOString()} ${vaultKey} ${announcement.tag} (by ${announcement.announcedBy})`); + } + await new Promise(resolve => setTimeout(resolve, intervalSeconds * 1000)); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index a7af986..e686ffa 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { identityInit, identityShow, identitySetId } from './commands/identity.js'; import { recipientAdd, recipientList, recipientRemove, adminOverview } from './commands/recipient.js'; -import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog } from './commands/vault.js'; +import { vaultPush, vaultPull, vaultGrant, vaultRevoke, vaultLog, vaultAnnounce, vaultWatch } from './commands/vault.js'; function flag(args: string[], name: string, fallback?: string): string | undefined { const i = args.indexOf(`--${name}`); @@ -35,6 +35,8 @@ async function main(): Promise { if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only')); if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]); if (cmd === 'log') return await vaultLog(sub); + if (cmd === 'announce') return await vaultAnnounce(sub, flag(rest, 'tag')!); + if (cmd === 'watch') return await vaultWatch(sub, Number(flag(rest, 'interval', '60'))); printUsage(); process.exitCode = cmd ? 1 : 0; @@ -56,6 +58,8 @@ function printUsage(): void { keep grant [--read-only] keep revoke (admin — needs KEEP_ADMIN_PASSWORD) keep log + keep announce --tag (requires any grant, read-only is enough) + keep watch [--interval 60] (polls for a new announced tag; ctrl-c to stop) keep recipient add --label