From d1f4cb6ba8dd163016dc7ec3cc223b865c3c8839 Mon Sep 17 00:00:00 2001 From: Jens Caers Date: Wed, 26 Aug 2026 23:20:26 +0200 Subject: [PATCH] feat(deploy): verify AppOps Ed25519 evidence before machine deploy --- examples/server/forgeflow-deploy | 67 +++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/examples/server/forgeflow-deploy b/examples/server/forgeflow-deploy index 707fa5d..7da8af7 100644 --- a/examples/server/forgeflow-deploy +++ b/examples/server/forgeflow-deploy @@ -4,18 +4,27 @@ umask 027 # Install as /usr/local/bin/forgeflow-deploy, owned by root and not writable by # the Gitea runner. Targets are read from the root-owned data file below. +# Approved machine deployments additionally verify an AppOps Ed25519 signature +# using the root-controlled public key; the Actions runner never receives that +# trust anchor's private key. readonly CONFIG_FILE="/etc/forgeflow/targets.conf" +readonly EVIDENCE_PUBLIC_KEY_FILE="/etc/forgeflow/evidence.pub" readonly REPOSITORY="${1:-}" readonly ENVIRONMENT="${2:-}" readonly SHA="${3:-}" readonly REQUEST_ID="${4:-manual-$(date +%s)}" +readonly APPROVAL_ID="${5:-}" +readonly APPROVAL_FINGERPRINT="${6:-}" +readonly EVIDENCE_ISSUED_AT="${7:-}" +readonly EVIDENCE_SIGNATURE="${8:-}" fail_usage() { - echo "Usage: forgeflow-deploy [request-id]" >&2 + echo "Usage: forgeflow-deploy [request-id] [approval-id approval-fingerprint evidence-issued-at evidence-signature]" >&2 exit 64 } +(( $# == 3 || $# == 4 || $# == 8 )) || fail_usage [[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage [[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage [[ "$SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail_usage @@ -29,6 +38,52 @@ config_mode="$(stat -c '%a' "$CONFIG_FILE")" # Reject group/other write bits. GNU stat returns an octal string such as 640. (( (8#$config_mode & 8#022) == 0 )) || { echo "Target configuration may not be group/other writable" >&2; exit 78; } +EVIDENCE_VERIFIED=false +if (( $# == 8 )); then + [[ "$REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]] || { echo "Approved deployment request ID is invalid" >&2; exit 64; } + [[ "$APPROVAL_ID" == "$REQUEST_ID" ]] || { echo "Approval ID must equal the immutable request ID" >&2; exit 65; } + [[ "$APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]] || { echo "Approval fingerprint is invalid" >&2; exit 64; } + [[ "$EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]] || { echo "Evidence timestamp is invalid" >&2; exit 64; } + [[ "$EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]] || { echo "Evidence signature encoding is invalid" >&2; exit 64; } + [[ -f "$EVIDENCE_PUBLIC_KEY_FILE" ]] || { echo "Missing AppOps evidence public key: $EVIDENCE_PUBLIC_KEY_FILE" >&2; exit 78; } + + evidence_owner="$(stat -c '%U' "$EVIDENCE_PUBLIC_KEY_FILE")" + evidence_mode="$(stat -c '%a' "$EVIDENCE_PUBLIC_KEY_FILE")" + [[ "$evidence_owner" == "root" ]] || { echo "Evidence public key must be owned by root" >&2; exit 78; } + (( (8#$evidence_mode & 8#022) == 0 )) || { echo "Evidence public key may not be group/other writable" >&2; exit 78; } + command -v openssl >/dev/null 2>&1 || { echo "OpenSSL is required for approved deployment evidence verification" >&2; exit 69; } + + now_epoch="$(date +%s)" + (( EVIDENCE_ISSUED_AT <= now_epoch + 60 )) || { echo "Deployment evidence is issued too far in the future" >&2; exit 65; } + (( EVIDENCE_ISSUED_AT >= now_epoch - 1800 )) || { echo "Deployment evidence expired before execution" >&2; exit 65; } + + evidence_tmp="$(mktemp -d /run/forgeflow-evidence.XXXXXX)" + cleanup_evidence() { rm -rf "$evidence_tmp"; } + trap cleanup_evidence EXIT + printf 'forgeflow-evidence-v1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n' \ + "$APPROVAL_ID" \ + "$APPROVAL_FINGERPRINT" \ + "$REPOSITORY" \ + "$ENVIRONMENT" \ + "${SHA,,}" \ + "$REQUEST_ID" \ + "$EVIDENCE_ISSUED_AT" > "$evidence_tmp/message" + printf '%s' "$EVIDENCE_SIGNATURE" | base64 --decode > "$evidence_tmp/signature" 2>/dev/null || { + echo "Deployment evidence signature could not be decoded" >&2 + exit 65 + } + openssl pkeyutl -verify \ + -pubin \ + -inkey "$EVIDENCE_PUBLIC_KEY_FILE" \ + -rawin \ + -in "$evidence_tmp/message" \ + -sigfile "$evidence_tmp/signature" >/dev/null 2>&1 || { + echo "Deployment evidence signature verification failed" >&2 + exit 65 + } + EVIDENCE_VERIFIED=true +fi + APP_DIR="" BRANCH="" COMPOSE_FILE="" @@ -75,6 +130,9 @@ write_status() { temporary="${STATUS_FILE}.${$}.tmp" json_string "$health" >/dev/null json_string "$REQUEST_ID" >/dev/null + json_string "$APPROVAL_ID" >/dev/null + json_string "$APPROVAL_FINGERPRINT" >/dev/null + json_string "$EVIDENCE_ISSUED_AT" >/dev/null [[ "$live_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid live SHA for status output" >&2; return 1; } [[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; } cat > "$temporary" <