harden(update): verify the self-update commit signature before the root re-exec/build

The re-audit's top finding (HIGH): update.sh runs as root, pulled + re-exec'd its own new copy +
ran install.sh's `docker compose build` on the pulled tree with NO signature check, so a
compromised/redirected origin or a transport MITM could feed attacker commits into a root RCE. The
branch-pin (bc2f2b5) stops a local `git checkout` redirect but not a remote origin compromise —
only a signature does.

- update.sh: after the pull and BEFORE the self-update re-exec / install.sh build, verify the pulled
  HEAD with `git -c gpg.ssh.allowedSignersFile=<anchor> verify-commit HEAD`. Opt-in and safe by
  default — skipped with a warning when no anchor is configured (existing installs keep updating),
  but FAIL CLOSED once configured. The anchor is resolved from .env CLUSEV_ALLOWED_SIGNERS or the
  gitignored .clusev-allowed-signers; a SET-but-missing anchor dies (no silent downgrade), and a
  git-TRACKED anchor is refused (a pulled tree could otherwise swap in the attacker's own key).
- clusev-release.sh: opt-in SSH signing of the release commit + tag when CLUSEV_SIGNING_KEY is set,
  so the verify side has provenance to check. Unsigned (identical behaviour) when unset.
- .gitignore /.clusev-allowed-signers (the trust anchor must never be pull-overwritable);
  .env.example documents CLUSEV_ALLOWED_SIGNERS.

verify-commit + allowed-signers proven end-to-end in a scratch repo (signed→pass, unsigned→fail,
wrong-key→fail, tracked-anchor→refuse, set-but-missing→fail-closed). shellcheck clean. Activation
(provision the maintainer keypair + sign releases) is a documented maintainer step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 17:10:19 +02:00
parent 46924135e6
commit 0943d56201
4 changed files with 79 additions and 8 deletions

View File

@ -111,6 +111,11 @@ SITE_ADDRESS=:80
CLUSEV_IMAGE=clusev-app:prod CLUSEV_IMAGE=clusev-app:prod
# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY. # HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY.
UPDATE_HMAC_KEY= UPDATE_HMAC_KEY=
# OPTIONAL supply-chain hardening: path to an SSH allowed-signers file. When set, update.sh verifies
# the pulled HEAD is signed by a trusted maintainer key before re-exec/build and REFUSES an unsigned
# or untrusted commit (fail-closed). Unset = verification skipped (a warning is logged). The file must
# stay outside the tracked tree (it is gitignored) so a pulled/MITM'd repo cannot swap the key.
# CLUSEV_ALLOWED_SIGNERS=/home/nexxo/clusev/.clusev-allowed-signers
# Shared secret between the app and the terminal sidecar (the /terminal page). The sidecar presents # Shared secret between the app and the terminal sidecar (the /terminal page). The sidecar presents
# it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates # it to the internal resolve endpoint that hands out decrypted SSH credentials. install.sh generates
# it; leave empty to DISABLE the terminal (the resolve endpoint then 403s). # it; leave empty to DISABLE the terminal (the resolve endpoint then 403s).

5
.gitignore vendored
View File

@ -16,6 +16,11 @@
!.env.example !.env.example
*.pem *.pem
# Self-update trust anchor: the SSH allowed-signers list update.sh checks the pulled HEAD against.
# MUST stay untracked so a pulled/MITM'd tree can never swap the trusted maintainer key (git pull
# leaves ignored files alone). Provisioned out-of-band; see the signature-verification spec.
/.clusev-allowed-signers
.phpunit.result.cache .phpunit.result.cache
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml

View File

@ -58,8 +58,23 @@ _do_stage() { # target
local ver="${target}-rc$((n + 1))" newtag="v${target}-rc$((n + 1))" local ver="${target}-rc$((n + 1))" newtag="v${target}-rc$((n + 1))"
sed -i -E "s/('version' => ')[^']*(')/\\1${ver}\\2/" "${PROJ}/config/clusev.php" sed -i -E "s/('version' => ')[^']*(')/\\1${ver}\\2/" "${PROJ}/config/clusev.php"
git -C "$PROJ" commit -q -am "chore(release): ${ver}" || { echo "commit-failed" >&2; git -C "$PROJ" checkout -- . 2>/dev/null || true; return 1; }
git -C "$PROJ" tag "$newtag" || { echo "tag-failed" >&2; _rollback "$newtag"; return 1; } # Opt-in SSH signing: when CLUSEV_SIGNING_KEY (path to the maintainer's SSH private key) is set in
# the release env, sign the release COMMIT (and tag) so update.sh's `verify-commit HEAD` can prove
# provenance and refuse an unsigned / attacker-substituted HEAD. Unsigned when unset — matching
# update.sh's opt-in verification (dormant until the operator provisions the keypair). The `-c`
# git-level flags go before the subcommand; the sign flags after. See the signature-verification spec.
local signkey gitc=() csign=() tsign=()
# shellcheck source=/dev/null
signkey="$( . "$GITEA_ENV" 2>/dev/null; printf '%s' "${CLUSEV_SIGNING_KEY:-}" )"
if [ -n "$signkey" ] && [ -f "$signkey" ]; then
gitc=(-c gpg.format=ssh -c "user.signingkey=${signkey}")
csign=(-S)
tsign=(-s -m "clusev ${newtag}")
fi
git -C "$PROJ" "${gitc[@]}" commit -q "${csign[@]}" -am "chore(release): ${ver}" || { echo "commit-failed" >&2; git -C "$PROJ" checkout -- . 2>/dev/null || true; return 1; }
git -C "$PROJ" "${gitc[@]}" tag "${tsign[@]}" "$newtag" || { echo "tag-failed" >&2; _rollback "$newtag"; return 1; }
local token origin host url local token origin host url
# shellcheck source=/dev/null # shellcheck source=/dev/null

View File

@ -7,11 +7,13 @@
# In order: # In order:
# 1. Mark the repo a safe git dir for root. install.sh chowns the tree to the `clusev` user, # 1. Mark the repo a safe git dir for root. install.sh chowns the tree to the `clusev` user,
# so a root-run `git pull` would otherwise refuse with "dubious ownership". # so a root-run `git pull` would otherwise refuse with "dubious ownership".
# 2. git pull --ff-only on the tracked branch. Aborts cleanly on any conflict / divergence — # 2. git pull --ff-only, PINNED to the recorded release branch. Aborts cleanly on any conflict /
# it NEVER discards local changes. # divergence — it NEVER discards local changes.
# 3. Self-update: if update.sh itself changed in the pull, re-exec the NEW copy once (guarded # 3. Verify the pulled HEAD's commit signature against a trusted maintainer key (opt-in via a
# gitignored allowed-signers file) BEFORE re-exec/build — fail-closed when configured.
# 4. Self-update: if update.sh itself changed in the pull, re-exec the NEW copy once (guarded
# against loops) so improvements to this very script take effect on the same run. # against loops) so improvements to this very script take effect on the same run.
# 4. Hand off to ./install.sh — idempotent: rebuilds the image, restarts the stack, migrates, # 5. Hand off to ./install.sh — idempotent: rebuilds the image, restarts the stack, migrates,
# refreshes the MOTD and fixes ownership. Run NON-interactively (stdin from /dev/null) so # refreshes the MOTD and fixes ownership. Run NON-interactively (stdin from /dev/null) so
# it never re-prompts; secrets and the configured domain / ACME e-mail are preserved. # it never re-prompts; secrets and the configured domain / ACME e-mail are preserved.
set -euo pipefail set -euo pipefail
@ -85,7 +87,51 @@ if [ "${CLUSEV_UPDATE_REEXEC:-0}" != 1 ]; then
|| die "git pull fehlgeschlagen — Timeout, fehlende Zugangsdaten zum privaten Repo, lokale Aenderungen oder divergierter Branch. Mit 'git status' und dem Repo-Zugriff (Token im origin-Remote) pruefen und erneut versuchen." || die "git pull fehlgeschlagen — Timeout, fehlende Zugangsdaten zum privaten Repo, lokale Aenderungen oder divergierter Branch. Mit 'git status' und dem Repo-Zugriff (Token im origin-Remote) pruefen und erneut versuchen."
after_hash="$(sha256sum -- "$0" | cut -d' ' -f1)" after_hash="$(sha256sum -- "$0" | cut -d' ' -f1)"
# 3. self-update: relaunch the new update.sh if it changed # 3. Verify the pulled HEAD is signed by a TRUSTED maintainer key BEFORE anything acts on it
# (the re-exec of this very script below, then the ROOT `docker compose build` in install.sh).
# The allowed-signers list lives in a GITIGNORED file — `.env` (CLUSEV_ALLOWED_SIGNERS, absolute
# path preferred) or the repo-root `.clusev-allowed-signers` — so a pulled or MITM'd tree can
# never swap the trusted key (git pull does not touch untracked/ignored files). This is the
# missing supply-chain control: the branch-pin above stops a local `git checkout` redirect, but
# only a signature stops a compromised ORIGIN / transport from feeding attacker commits to root.
# OPT-IN: with no signer list configured this is a loud no-op so existing installs keep updating;
# once configured, a bad OR absent signature FAILS CLOSED. Activate it by provisioning the
# maintainer public key + signing releases — see docs/…/self-update-signature-verification.md.
# Read the .env value BARE (no quote-stripping) — force_kv/set_kv write unquoted values, same as
# every other .env read here (CLUSEV_BUILD_BRANCH above, watch.sh); a quoted value simply won't
# resolve to a file and so fails closed below.
signers="$(grep -m1 '^CLUSEV_ALLOWED_SIGNERS=' .env 2>/dev/null | cut -d= -f2-)"
if [ -n "$signers" ]; then
# CLUSEV_ALLOWED_SIGNERS explicitly set => verification is REQUIRED. A missing/unreadable file
# must FAIL CLOSED, never silently downgrade to "not configured" (that fail-open would let an
# attacker who can remove the anchor skip verification entirely).
[ -f "$signers" ] && [ -r "$signers" ] \
|| die "CLUSEV_ALLOWED_SIGNERS ist gesetzt (${signers}), aber die Datei fehlt oder ist unlesbar — Update abgebrochen. Signaturpruefung darf nicht stillschweigend ausfallen."
elif [ -f "$REPO_DIR/.clusev-allowed-signers" ]; then
signers="$REPO_DIR/.clusev-allowed-signers"
fi
if [ -n "$signers" ]; then
# The trust anchor must NOT be repointable or swappable via a pull. Reject a SYMLINK first: `-f`
# follows the link and `git ls-files` checks the LINK path (not its target), so an untracked
# symlink to a tracked, pull-overwritable file would otherwise slip past the tracked-check below.
if [ -L "$signers" ]; then
die "Der allowed-signers Trust-Anchor (${signers}) ist ein Symlink — nicht erlaubt (koennte auf eine versionierte, per Pull austauschbare Datei zeigen). Bitte eine echte Datei verwenden."
fi
# And it must NOT be git-tracked (pull-overwritable): otherwise a malicious origin could ship its
# OWN allowed-signers file in the very commit being verified and pass the check against the
# attacker's key. Only untracked/gitignored or out-of-tree paths are trusted.
if git ls-files --error-unmatch -- "$signers" >/dev/null 2>&1; then
die "Der allowed-signers Trust-Anchor (${signers}) ist git-versioniert und damit ueber einen Pull austauschbar — Update abgebrochen. Die Datei muss untracked/gitignored sein (siehe .clusev-allowed-signers) oder ausserhalb des Arbeitsbaums liegen."
fi
info "Verifiziere Signatur des gepullten Commits (${signers}) ..."
git -c gpg.ssh.allowedSignersFile="$signers" verify-commit HEAD \
|| die "Signaturpruefung fehlgeschlagen — HEAD ist NICHT von einem vertrauten Maintainer-Schluessel signiert. Update abgebrochen (moegliche Kompromittierung von Origin oder Transport). Der Stack laeuft unveraendert weiter."
info "Signatur ok."
else
info "Hinweis: Commit-Signaturpruefung nicht konfiguriert (CLUSEV_ALLOWED_SIGNERS bzw. .clusev-allowed-signers) — uebersprungen."
fi
# 4. self-update: relaunch the new (now signature-verified) update.sh if it changed
if [ "$before_hash" != "$after_hash" ]; then if [ "$before_hash" != "$after_hash" ]; then
info "update.sh wurde aktualisiert — starte die neue Version ..." info "update.sh wurde aktualisiert — starte die neue Version ..."
exec env CLUSEV_UPDATE_REEXEC=1 "$0" "$@" exec env CLUSEV_UPDATE_REEXEC=1 "$0" "$@"
@ -95,6 +141,6 @@ fi
# Refresh the update source from the (possibly changed) clone origin before handing off. # Refresh the update source from the (possibly changed) clone origin before handing off.
[ -x ./scripts/set-repository-url.sh ] && ./scripts/set-repository-url.sh "$(pwd)" ./.env || true [ -x ./scripts/set-repository-url.sh ] && ./scripts/set-repository-url.sh "$(pwd)" ./.env || true
# 4. apply via the idempotent installer, non-interactively (no prompts; config preserved) # 5. apply via the idempotent installer, non-interactively (no prompts; config preserved)
info "Wende Update an (install.sh) ..." info "Wende Update an (install.sh) ..."
exec ./install.sh </dev/null exec ./install.sh </dev/null