diff --git a/.gitea/workflows/chatgpt-validation.yml b/.gitea/workflows/chatgpt-validation.yml new file mode 100644 index 0000000..dc45b60 --- /dev/null +++ b/.gitea/workflows/chatgpt-validation.yml @@ -0,0 +1,29 @@ +name: ChatGPT validation + +on: + push: + branches: + - 'codex/**' + workflow_dispatch: + +jobs: + quality: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run quality + - run: npx playwright install --with-deps chromium + - run: npm run test:browser:ci + - name: Preserve browser failure evidence + if: failure() + uses: actions/upload-artifact@v4 + with: + name: forgeflow-browser-failure-evidence + path: artifacts/ + if-no-files-found: ignore + - run: npm audit --omit=dev --audit-level=high diff --git a/examples/gitea-actions/forgeflow-approved-deploy.yml b/examples/gitea-actions/forgeflow-approved-deploy.yml new file mode 100644 index 0000000..857c953 --- /dev/null +++ b/examples/gitea-actions/forgeflow-approved-deploy.yml @@ -0,0 +1,90 @@ +name: ForgeFlow approved deploy + +on: + workflow_dispatch: + inputs: + repository: + description: Signed allowlisted deployment target (owner/repository) + required: true + type: string + environment: + description: Allowlisted ForgeFlow environment + required: true + type: string + commit_sha: + description: Exact approved commit SHA + required: true + type: string + request_id: + description: Immutable AppOps request identifier + required: true + type: string + approval_id: + description: AppOps approval identifier + required: true + type: string + approval_fingerprint: + description: Immutable AppOps approval fingerprint + required: true + type: string + evidence_issued_at: + description: Signed evidence UNIX timestamp + required: true + type: string + evidence_signature: + description: Base64 Ed25519 signature over the exact deployment evidence + required: true + type: string + +concurrency: + group: forgeflow-approved-${{ inputs.repository }}-${{ inputs.environment }} + cancel-in-progress: false + +jobs: + deploy: + runs-on: forgeflow + steps: + - name: Validate signed deployment inputs + shell: bash + env: + FF_REPOSITORY: ${{ inputs.repository }} + FF_ENVIRONMENT: ${{ inputs.environment }} + FF_COMMIT_SHA: ${{ inputs.commit_sha }} + FF_REQUEST_ID: ${{ inputs.request_id }} + FF_APPROVAL_ID: ${{ inputs.approval_id }} + FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }} + FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }} + FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }} + run: | + set -Eeuo pipefail + [[ "$FF_REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] + [[ "$FF_ENVIRONMENT" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] + [[ "$FF_COMMIT_SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] + [[ "$FF_REQUEST_ID" =~ ^appr-[A-Za-z0-9._-]{1,75}$ ]] + [[ "$FF_APPROVAL_ID" == "$FF_REQUEST_ID" ]] + [[ "$FF_APPROVAL_FINGERPRINT" =~ ^[0-9a-f]{64}$ ]] + [[ "$FF_EVIDENCE_ISSUED_AT" =~ ^[0-9]{10,11}$ ]] + [[ "$FF_EVIDENCE_SIGNATURE" =~ ^[A-Za-z0-9+/]{86}==$ ]] + + - name: Execute root-owned verified deployment + shell: bash + env: + FF_REPOSITORY: ${{ inputs.repository }} + FF_ENVIRONMENT: ${{ inputs.environment }} + FF_COMMIT_SHA: ${{ inputs.commit_sha }} + FF_REQUEST_ID: ${{ inputs.request_id }} + FF_APPROVAL_ID: ${{ inputs.approval_id }} + FF_APPROVAL_FINGERPRINT: ${{ inputs.approval_fingerprint }} + FF_EVIDENCE_ISSUED_AT: ${{ inputs.evidence_issued_at }} + FF_EVIDENCE_SIGNATURE: ${{ inputs.evidence_signature }} + run: | + set -Eeuo pipefail + sudo /usr/local/bin/forgeflow-deploy \ + "$FF_REPOSITORY" \ + "$FF_ENVIRONMENT" \ + "$FF_COMMIT_SHA" \ + "$FF_REQUEST_ID" \ + "$FF_APPROVAL_ID" \ + "$FF_APPROVAL_FINGERPRINT" \ + "$FF_EVIDENCE_ISSUED_AT" \ + "$FF_EVIDENCE_SIGNATURE" diff --git a/examples/server/forgeflow-deploy b/examples/server/forgeflow-deploy index 707fa5d..4218671 100644 --- a/examples/server/forgeflow-deploy +++ b/examples/server/forgeflow-deploy @@ -4,18 +4,29 @@ 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. Every verified approval id is consumed exactly +# once in a root-owned replay journal before target lookup or mutation. readonly CONFIG_FILE="/etc/forgeflow/targets.conf" +readonly EVIDENCE_PUBLIC_KEY_FILE="/etc/forgeflow/evidence.pub" +readonly EVIDENCE_REPLAY_DIR="/var/lib/forgeflow-status/approved-requests" 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 +40,61 @@ 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 + } + + # Consume the verified approval before any target lookup. mkdir is atomic, + # making this a cross-process replay fence. A failed first deployment still + # requires a fresh human approval, matching AppOps' terminal execution model. + install -d -o root -g root -m 0700 "$EVIDENCE_REPLAY_DIR" + if ! mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID" 2>/dev/null; then + echo "Approved deployment evidence was already consumed" >&2 + exit 65 + fi + EVIDENCE_VERIFIED=true +fi + APP_DIR="" BRANCH="" COMPOSE_FILE="" @@ -75,6 +141,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" < { + const workflow = await readFile(workflowUrl, 'utf8'); + + for (const input of [ + 'repository', + 'environment', + 'commit_sha', + 'request_id', + 'approval_id', + 'approval_fingerprint', + 'evidence_issued_at', + 'evidence_signature', + ]) { + assert.match(workflow, new RegExp(`\\b${input}:`)); + } + assert.match(workflow, /\$\{\{ inputs\.repository \}\}/); + assert.doesNotMatch(workflow, /\$\{\{ gitea\.repository \}\}/); + assert.match(workflow, /FF_APPROVAL_ID.*FF_REQUEST_ID/s); + assert.match(workflow, /sudo \/usr\/local\/bin\/forgeflow-deploy/); + assert.doesNotMatch(workflow, /actions\/checkout/); + assert.doesNotMatch(workflow, /docker compose/); + assert.doesNotMatch(workflow, /git\s+-C/); +}); + + +test('server wrapper verifies Ed25519 evidence before any live git or compose mutation', async () => { + const script = await readFile(deployUrl, 'utf8'); + const verifyIndex = script.indexOf('openssl pkeyutl -verify'); + const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"'); + const composeIndex = script.indexOf('docker compose -f "$COMPOSE_FILE" up -d --build'); + + assert.ok(verifyIndex > 0, 'expected cryptographic verification'); + assert.ok(resetIndex > verifyIndex, 'git reset must happen after evidence verification'); + assert.ok(composeIndex > verifyIndex, 'compose mutation must happen after evidence verification'); + assert.match(script, /EVIDENCE_PUBLIC_KEY_FILE="\/etc\/forgeflow\/evidence\.pub"/); + assert.match(script, /evidence_owner.*root/s); + assert.match(script, /8#022/); + assert.match(script, /EVIDENCE_ISSUED_AT >= now_epoch - 1800/); + assert.match(script, /"evidence_verified": \$EVIDENCE_VERIFIED/); + assert.match(script, /\(\( \$# == 3 \|\| \$# == 4 \|\| \$# == 8 \)\)/); +}); + + +test('signed message fields match the AppOps evidence v1 contract and exclude runner-chosen workflow/ref', async () => { + const script = await readFile(deployUrl, 'utf8'); + const marker = "printf 'forgeflow-evidence-v1\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n'"; + assert.ok(script.includes(marker)); + assert.match( + script, + /"\$APPROVAL_ID"[\s\\]+"\$APPROVAL_FINGERPRINT"[\s\\]+"\$REPOSITORY"[\s\\]+"\$ENVIRONMENT"[\s\\]+"\$\{SHA,,\}"[\s\\]+"\$REQUEST_ID"[\s\\]+"\$EVIDENCE_ISSUED_AT"/s, + ); + assert.doesNotMatch(script.slice(0, script.indexOf('APP_DIR=""')), /WORKFLOW|workflow|ref=/); +}); diff --git a/tests/approved-deployment-one-shot.test.mjs b/tests/approved-deployment-one-shot.test.mjs new file mode 100644 index 0000000..28bd943 --- /dev/null +++ b/tests/approved-deployment-one-shot.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url); + +test('a signed approved request is consumed once before target selection or mutation', async () => { + const script = await readFile(deployUrl, 'utf8'); + const verifyIndex = script.indexOf('openssl pkeyutl -verify'); + const consumeIndex = script.indexOf('mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID"'); + const targetIndex = script.indexOf('APP_DIR=""'); + const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"'); + + assert.ok(verifyIndex > 0); + assert.ok(consumeIndex > verifyIndex); + assert.ok(targetIndex > consumeIndex); + assert.ok(resetIndex > consumeIndex); + assert.match(script, /EVIDENCE_REPLAY_DIR="\/var\/lib\/forgeflow-status\/approved-requests"/); + assert.match(script, /install -d -o root -g root -m 0700 "\$EVIDENCE_REPLAY_DIR"/); + assert.match(script, /Approved deployment evidence was already consumed/); +});