ADR-0064 — Sovereign Self-Update: Check-Only Service + Signature-Verified CLI Apply
Status: Accepted Date: 2026-06-19 Deciders: VayuPress Maintainers
Context
Operators asked for a one-click, in-panel "Backup & Upgrade" button that would download a new VayuPress binary from GitHub Releases and replace the running process. That ergonomics goal is real, but the naïve implementation is a remote-code-execution-by-design feature: a web-triggered control path that fetches an executable from the internet and runs it. For a project whose constitution is sovereignty-first, governance-first, security-obsessed, that is the single largest attack surface we could add.
Two failure modes make the naïve design unacceptable:
- Integrity ≠ authenticity. A SHA-256 checksum only proves the bytes match the published checksum file. If the release channel (or the checksum file) is compromised, SHA-256 happily verifies attacker-supplied bytes. Authenticity requires a signature checked against a key the operator pinned out-of-band.
- Web-reachable self-replacement. Any auth bypass, CSRF gap, or SSRF on a "upgrade now" endpoint becomes full host compromise. The blast radius of a bug on that route is the whole server.
Decision
Split the feature into a read-only check service and a gated, CLI-only
apply path. Implemented in internal/update.
Read-only check (safe, web-exposed)
GET /admin/api/updates/checkqueries the GitHub Releases API, compares the running version with the latest tag, and returns{current, latest, updateAvailable, changelog}.- It writes a
checkedrow toupdate_historyfor audit. It performs no mutation, download, or restart. This is the only update-related route exposed over HTTP.
Signature-verified apply (CLI-only, multiply gated)
vayupress update apply [--dry-run] enforces, in order, before anything is
replaced:
- Opt-in. Refuses unless
VAYU_SELFUPDATE_ENABLED=true. Off by default. - Pinned key required. Refuses unless
VAYU_RELEASE_PUBKEY(hex Ed25519 public key) is set. The operator pins this out-of-band; it is never fetched. - Mode-gated. Refuses in
read-only,quarantined, ormaintenancemode. - Checksum + signature. Downloads the release binary, verifies its SHA-256 against the published checksum (constant-time compare), then verifies an Ed25519 signature over that digest against the pinned public key. Either check failing aborts with no filesystem change.
- Backup-first. Snapshots the SQLite database (+ WAL/SHM) to a timestamped
.tar.gzbefore touching the binary. - Atomic, reversible swap. Writes the verified binary beside the current
one and
os.Renames it into place, keeping<binary>.bak. It does not exec or restart — it prints instructions for the operator to restart viasystemd. Restart remains an operator action, not an automatic one.
--dry-run performs every verification step and reports the result without
replacing the binary.
All attempts (checked, started, success, failed, rolled_back) are
recorded in update_history (migration 017-update-history).
Consequences
- The web surface gains exactly one read-only endpoint. There is no HTTP path that downloads, replaces, or restarts — eliminating the RCE class entirely.
- Authenticity is enforced cryptographically against an operator-pinned key, not inferred from a checksum file that shares a trust domain with the artifact.
- Upgrades remain sovereign and auditable: opt-in, mode-respecting, backed up, signed, and logged. A compromised GitHub release cannot produce a running attacker binary without also holding the operator's offline signing key.
- Trade-off: applying an update is a deliberate shell action, not a button. This is intentional — the privilege to replace the running binary should require shell access to the host, which already implies full control.
- Future work (not in this ADR): per-release signature manifests published by CI,
and an optional
systemd-side helper for operators who want supervised restart after a verified swap.