feat(deploy): verify AppOps Ed25519 evidence before machine deploy
This commit is contained in:
@@ -4,18 +4,27 @@ umask 027
|
|||||||
|
|
||||||
# Install as /usr/local/bin/forgeflow-deploy, owned by root and not writable by
|
# 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.
|
# 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 CONFIG_FILE="/etc/forgeflow/targets.conf"
|
||||||
|
readonly EVIDENCE_PUBLIC_KEY_FILE="/etc/forgeflow/evidence.pub"
|
||||||
readonly REPOSITORY="${1:-}"
|
readonly REPOSITORY="${1:-}"
|
||||||
readonly ENVIRONMENT="${2:-}"
|
readonly ENVIRONMENT="${2:-}"
|
||||||
readonly SHA="${3:-}"
|
readonly SHA="${3:-}"
|
||||||
readonly REQUEST_ID="${4:-manual-$(date +%s)}"
|
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() {
|
fail_usage() {
|
||||||
echo "Usage: forgeflow-deploy <owner/repository> <environment> <full-sha> [request-id]" >&2
|
echo "Usage: forgeflow-deploy <owner/repository> <environment> <full-sha> [request-id] [approval-id approval-fingerprint evidence-issued-at evidence-signature]" >&2
|
||||||
exit 64
|
exit 64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(( $# == 3 || $# == 4 || $# == 8 )) || fail_usage
|
||||||
[[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage
|
[[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage
|
||||||
[[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage
|
[[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage
|
||||||
[[ "$SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || 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.
|
# 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; }
|
(( (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=""
|
APP_DIR=""
|
||||||
BRANCH=""
|
BRANCH=""
|
||||||
COMPOSE_FILE=""
|
COMPOSE_FILE=""
|
||||||
@@ -75,6 +130,9 @@ write_status() {
|
|||||||
temporary="${STATUS_FILE}.${$}.tmp"
|
temporary="${STATUS_FILE}.${$}.tmp"
|
||||||
json_string "$health" >/dev/null
|
json_string "$health" >/dev/null
|
||||||
json_string "$REQUEST_ID" >/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; }
|
[[ "$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; }
|
[[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; }
|
||||||
cat > "$temporary" <<JSON
|
cat > "$temporary" <<JSON
|
||||||
@@ -85,6 +143,10 @@ write_status() {
|
|||||||
"commit_sha": "$live_sha",
|
"commit_sha": "$live_sha",
|
||||||
"previous_sha": "$previous_sha",
|
"previous_sha": "$previous_sha",
|
||||||
"requested_sha": "$SHA",
|
"requested_sha": "$SHA",
|
||||||
|
"approval_id": "$APPROVAL_ID",
|
||||||
|
"approval_fingerprint": "$APPROVAL_FINGERPRINT",
|
||||||
|
"evidence_verified": $EVIDENCE_VERIFIED,
|
||||||
|
"evidence_issued_at": "$EVIDENCE_ISSUED_AT",
|
||||||
"deployed_at": "$deployed_at",
|
"deployed_at": "$deployed_at",
|
||||||
"health": "$health",
|
"health": "$health",
|
||||||
"last_exit_code": $exit_code
|
"last_exit_code": $exit_code
|
||||||
@@ -105,6 +167,9 @@ echo "ForgeFlow request: $REQUEST_ID"
|
|||||||
echo "Target: $REPOSITORY / $ENVIRONMENT"
|
echo "Target: $REPOSITORY / $ENVIRONMENT"
|
||||||
echo "Current SHA: $current_sha"
|
echo "Current SHA: $current_sha"
|
||||||
echo "Requested SHA: $SHA"
|
echo "Requested SHA: $SHA"
|
||||||
|
if [[ "$EVIDENCE_VERIFIED" == "true" ]]; then
|
||||||
|
echo "Approval: $APPROVAL_ID (signed evidence verified)"
|
||||||
|
fi
|
||||||
|
|
||||||
on_error() {
|
on_error() {
|
||||||
local exit_code=$?
|
local exit_code=$?
|
||||||
|
|||||||
Reference in New Issue
Block a user