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 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 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
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 # admin operations — gated by ADMIN_PASSWORD, see below
keep recipient add --label "..." --pubkey <hex> 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 "one person or small team running their own homelab," which is the
actual scale this is built for. 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 ## Deploy integration
The actual motivating use case — a deploy script pulling secrets instead The actual motivating use case — a deploy script pulling secrets instead

View File

@@ -1,8 +1,15 @@
# Proposal: announce + a self-deploying agent, with a no-`keep` fallback # Proposal: announce + a self-deploying agent, with a no-`keep` fallback
**Status:** proposed, not built. Self-contained — written so a fresh agent **Status:** implemented (§1§3, §6 shape). `announce`/`watch`, the
with no prior conversation context can pick this up and implement it `vault_announcements` table, and the API routes described below are
without anything explained first. 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) ## Context (read this first)

View File

@@ -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 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. 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 <short-sha>
# 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 ## Docker
```bash ```bash
@@ -177,12 +200,16 @@ CLI-side: `KEEP_SERVER_URL` (default `http://localhost:3050`),
Implemented: identity, push/pull/grant/revoke/log, read/write grant Implemented: identity, push/pull/grant/revoke/log, read/write grant
scoping, one-step rollback with an explicit purge mode for scoping, one-step rollback with an explicit purge mode for
compromise-driven rotations, admin recipient management, Docker deploy. compromise-driven rotations, admin recipient management, an admin
Verified end-to-end — two and three independent identities, read-only cross-vault `overview`, deploy-signal `announce`/`watch`, a
grants correctly blocked from push/grant, grants preserved across `scripts/deploy.sh` wrapper, and Docker deploy. Verified end-to-end —
rotation, previous-version pull working and correctly wiped by two and three independent identities, read-only grants correctly
`--purge` for every recipient, malformed-auth rejection — against both blocked from push/grant, grants preserved across rotation,
a local server and the built Docker image. See 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 [IMPLEMENTATION.md](./IMPLEMENTATION.md) for the full design and its
resolved decisions (per-secret-key granularity deliberately not built — resolved decisions (per-secret-key granularity deliberately not built —
use multiple vaults instead). use multiple vaults instead).

View File

@@ -114,3 +114,35 @@ export async function vaultLog(vaultKey: string): Promise<void> {
console.log(`${new Date(e.accessed_at * 1000).toISOString()} ${e.action.padEnd(6)} ${e.recipient_id}`); 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<void> {
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<void> {
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));
}
}

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
import { identityInit, identityShow, identitySetId } from './commands/identity.js'; import { identityInit, identityShow, identitySetId } from './commands/identity.js';
import { recipientAdd, recipientList, recipientRemove, adminOverview } from './commands/recipient.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 { function flag(args: string[], name: string, fallback?: string): string | undefined {
const i = args.indexOf(`--${name}`); const i = args.indexOf(`--${name}`);
@@ -35,6 +35,8 @@ async function main(): Promise<void> {
if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only')); if (cmd === 'grant') return await vaultGrant(sub, rest[0], boolFlag(rest, 'read-only'));
if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]); if (cmd === 'revoke') return await vaultRevoke(sub, rest[0]);
if (cmd === 'log') return await vaultLog(sub); 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(); printUsage();
process.exitCode = cmd ? 1 : 0; process.exitCode = cmd ? 1 : 0;
@@ -56,6 +58,8 @@ function printUsage(): void {
keep grant <vault> <recipient-id> [--read-only] keep grant <vault> <recipient-id> [--read-only]
keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD) keep revoke <vault> <recipient-id> (admin — needs KEEP_ADMIN_PASSWORD)
keep log <vault> keep log <vault>
keep announce <vault> --tag <tag> (requires any grant, read-only is enough)
keep watch <vault> [--interval 60] (polls for a new announced tag; ctrl-c to stop)
keep recipient add --label <label> --pubkey <hex> (admin) keep recipient add --label <label> --pubkey <hex> (admin)
keep recipient list (admin) keep recipient list (admin)

View File

@@ -61,6 +61,17 @@ db.exec(`
action TEXT NOT NULL, action TEXT NOT NULL,
accessed_at INTEGER NOT NULL accessed_at INTEGER NOT NULL
); );
-- Latest known deploy tag for a vault — "current state", not a
-- history, same one-row-per-vault shape as the vaults table's own
-- prev_ciphertext retention. An image tag isn't a secret, so this
-- lives outside the encrypted payload entirely.
CREATE TABLE IF NOT EXISTS vault_announcements (
vault_key TEXT PRIMARY KEY,
tag TEXT NOT NULL,
announced_at INTEGER NOT NULL,
announced_by TEXT NOT NULL
);
`); `);
export interface VaultRow { export interface VaultRow {
@@ -88,6 +99,13 @@ export interface GrantRow {
granted_at: number; granted_at: number;
} }
export interface AnnouncementRow {
vault_key: string;
tag: string;
announced_at: number;
announced_by: string;
}
export function getVault(vaultKey: string): VaultRow | undefined { export function getVault(vaultKey: string): VaultRow | undefined {
return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined; return db.prepare(`SELECT * FROM vaults WHERE vault_key = ?`).get(vaultKey) as VaultRow | undefined;
} }
@@ -225,7 +243,7 @@ export function deleteGrant(vaultKey: string, recipientId: string): void {
db.prepare(`DELETE FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`).run(vaultKey, recipientId); db.prepare(`DELETE FROM vault_grants WHERE vault_key = ? AND recipient_id = ?`).run(vaultKey, recipientId);
} }
export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke'): void { export function logAccess(vaultKey: string, recipientId: string, action: 'pull' | 'push' | 'grant' | 'revoke' | 'announce'): void {
db.prepare( db.prepare(
`INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`, `INSERT INTO access_log (vault_key, recipient_id, action, accessed_at) VALUES (?, ?, ?, ?)`,
).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000)); ).run(vaultKey, recipientId, action, Math.floor(Date.now() / 1000));
@@ -236,3 +254,17 @@ export function getAccessLog(vaultKey: string, limit = 50): { recipient_id: stri
`SELECT recipient_id, action, accessed_at FROM access_log WHERE vault_key = ? ORDER BY accessed_at DESC LIMIT ?`, `SELECT recipient_id, action, accessed_at FROM access_log WHERE vault_key = ? ORDER BY accessed_at DESC LIMIT ?`,
).all(vaultKey, limit) as { recipient_id: string; action: string; accessed_at: number }[]; ).all(vaultKey, limit) as { recipient_id: string; action: string; accessed_at: number }[];
} }
// One row per vault — "latest known tag", not a history. Upsert.
export function setAnnouncement(vaultKey: string, tag: string, announcedBy: string): void {
db.prepare(
`INSERT INTO vault_announcements (vault_key, tag, announced_at, announced_by)
VALUES (?, ?, ?, ?)
ON CONFLICT(vault_key) DO UPDATE SET
tag = excluded.tag, announced_at = excluded.announced_at, announced_by = excluded.announced_by`,
).run(vaultKey, tag, Math.floor(Date.now() / 1000), announcedBy);
}
export function getAnnouncement(vaultKey: string): AnnouncementRow | undefined {
return db.prepare(`SELECT * FROM vault_announcements WHERE vault_key = ?`).get(vaultKey) as AnnouncementRow | undefined;
}

View File

@@ -11,6 +11,8 @@ import {
logAccess, logAccess,
getAccessLog, getAccessLog,
listRecipients, listRecipients,
setAnnouncement,
getAnnouncement,
} from './db.js'; } from './db.js';
import { requireRecipientAuth, type AuthedRequest } from './auth.js'; import { requireRecipientAuth, type AuthedRequest } from './auth.js';
@@ -179,3 +181,42 @@ router.get('/vaults/:key/log', (req: AuthedRequest, res) => {
} }
res.json(getAccessLog(vaultKey)); res.json(getAccessLog(vaultKey));
}); });
// A deploy tag isn't a secret — it's not stored in the encrypted payload,
// just a small side table. Only requires ANY grant (read-only is enough)
// since announcing isn't a mutation of vault secrets — a CI identity
// should never need write access on a vault just to say "there's a new
// image". Resist reaching for hasWriteGrant here by analogy with push.
router.post('/vaults/:key/announce', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
const recipientId = req.recipientId!;
if (!hasGrant(vaultKey, recipientId)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
const body = req.body as { tag?: string };
if (!body.tag) {
res.status(400).json({ error: 'tag is required' });
return;
}
setAnnouncement(vaultKey, body.tag, recipientId);
logAccess(vaultKey, recipientId, 'announce');
res.json({ ok: true });
});
router.get('/vaults/:key/announce', (req: AuthedRequest, res) => {
const vaultKey = req.params.key;
if (!hasGrant(vaultKey, req.recipientId!)) {
res.status(403).json({ error: 'no access to this vault' });
return;
}
const announcement = getAnnouncement(vaultKey);
if (!announcement) {
res.status(404).json({ error: 'nothing announced yet' });
return;
}
res.json({ tag: announcement.tag, announcedAt: announcement.announced_at, announcedBy: announcement.announced_by });
});