diff --git a/.github/workflows/build-release-candidate.yml b/.github/workflows/build-release-candidate.yml index 626ff160d..f83856238 100644 --- a/.github/workflows/build-release-candidate.yml +++ b/.github/workflows/build-release-candidate.yml @@ -89,6 +89,9 @@ jobs: artifact_digest: ${{ steps.wait.outputs.artifact_digest }} artifact_name: ${{ steps.wait.outputs.artifact_name }} compiler_run_id: ${{ steps.dispatch.outputs.compiler_run_id }} + secure_runtime_artifact_id: ${{ steps.wait.outputs.secure_runtime_artifact_id }} + secure_runtime_artifact_digest: ${{ steps.wait.outputs.secure_runtime_artifact_digest }} + secure_runtime_artifact_name: ${{ steps.wait.outputs.secure_runtime_artifact_name }} steps: - name: Dispatch exact-SHA compiler workflow id: dispatch @@ -172,6 +175,17 @@ jobs: echo "artifact_digest=${artifact_digest}" >> "$GITHUB_OUTPUT" echo "artifact_name=${artifact_name}" >> "$GITHUB_OUTPUT" + secure_runtime_artifact_name="secure-runtime-qualification-${SOURCE_SHA}-${VERSION}-${REQUEST_ID}" + secure_runtime_artifact_json="$(jq -ce \ + --arg artifact_name "${secure_runtime_artifact_name}" \ + '[.artifacts[] | select(.name == $artifact_name and .expired == false and .size_in_bytes > 0)] | if length == 1 then .[0] else error("expected exactly one secure-runtime compiler artifact") end' \ + <<<"${artifacts_json}")" + secure_runtime_artifact_id="$(jq -er '.id | select(type == "number")' <<<"${secure_runtime_artifact_json}")" + secure_runtime_artifact_digest="$(jq -er '.digest | select(test("^sha256:[0-9a-f]{64}$")) | sub("^sha256:"; "")' <<<"${secure_runtime_artifact_json}")" + echo "secure_runtime_artifact_id=${secure_runtime_artifact_id}" >> "$GITHUB_OUTPUT" + echo "secure_runtime_artifact_digest=${secure_runtime_artifact_digest}" >> "$GITHUB_OUTPUT" + echo "secure_runtime_artifact_name=${secure_runtime_artifact_name}" >> "$GITHUB_OUTPUT" + signing-configuration: name: Verify Native Signing Configuration if: ${{ inputs.require_macos_signing || inputs.require_windows_signing }} @@ -731,6 +745,65 @@ jobs: mkdir -p release-compiled unzip -q "${artifact_zip}" -d release-compiled + - name: Verify hosted secure-runtime compiler packet + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_ARTIFACT_ID: ${{ needs.obtain-release-payload.outputs.secure_runtime_artifact_id }} + EXPECTED_ARTIFACT_DIGEST: ${{ needs.obtain-release-payload.outputs.secure_runtime_artifact_digest }} + EXPECTED_ARTIFACT_NAME: ${{ needs.obtain-release-payload.outputs.secure_runtime_artifact_name }} + EXPECTED_COMPILER_RUN_ID: ${{ needs.obtain-release-payload.outputs.compiler_run_id }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + [[ "${EXPECTED_ARTIFACT_ID}" =~ ^[0-9]+$ ]] + [[ "${EXPECTED_ARTIFACT_DIGEST}" =~ ^[0-9a-f]{64}$ ]] + artifact_json="$RUNNER_TEMP/secure-runtime-compiled-artifact.json" + artifact_zip="$RUNNER_TEMP/secure-runtime-compiled-artifact.zip" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${EXPECTED_ARTIFACT_ID}" > "${artifact_json}" + jq -e \ + --argjson artifact_id "${EXPECTED_ARTIFACT_ID}" \ + --arg artifact_name "${EXPECTED_ARTIFACT_NAME}" \ + --arg artifact_digest "sha256:${EXPECTED_ARTIFACT_DIGEST}" \ + --argjson run_id "${EXPECTED_COMPILER_RUN_ID}" \ + --arg source_sha "${GITHUB_SHA}" \ + '.id == $artifact_id and .name == $artifact_name and .expired == false and .size_in_bytes > 0 and .digest == $artifact_digest and .workflow_run.id == $run_id and .workflow_run.head_sha == $source_sha' \ + "${artifact_json}" >/dev/null + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${EXPECTED_ARTIFACT_ID}/zip" > "${artifact_zip}" + printf '%s %s\n' "${EXPECTED_ARTIFACT_DIGEST}" "${artifact_zip}" | sha256sum --check -- + mkdir -p secure-runtime-qualification + unzip -q "${artifact_zip}" -d secure-runtime-qualification + + jq -e \ + --arg version "${VERSION}" \ + --arg tag "v${VERSION}" \ + --arg source_sha "${GITHUB_SHA}" \ + '.schema_version == 1 and .version == $version and .tag == $tag and .source_sha == $source_sha and .compiler_runner_trust == "github-hosted-deny-self-hosted" and (.artifacts | keys | sort) == ["collector_v1", "collector_v2", "collector_v3", "collector_v4", "helper", "runner"]' \ + secure-runtime-qualification/secure-runtime-build-contract-v1.json >/dev/null + ( + cd secure-runtime-qualification + sha256sum --check secure-runtime-compiler-subjects.sha256 + ) + for subject in \ + pulse-secure-runtime-collector-v1-linux-amd64 \ + pulse-secure-runtime-collector-v2-linux-amd64 \ + pulse-secure-runtime-collector-v3-linux-amd64 \ + pulse-agent-linux-amd64 \ + pulse-agent-helper-linux-amd64 \ + pulse-agent-runner-linux-amd64 \ + secure-runtime-build-contract-v1.json; do + gh attestation verify \ + "secure-runtime-qualification/${subject}" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "github.com/rcourtman/Pulse/.github/workflows/compile-release-payload.yml" \ + --source-digest "${GITHUB_SHA}" \ + --deny-self-hosted-runners \ + --predicate-type "https://slsa.dev/provenance/v1" \ + --bundle secure-runtime-qualification/secure-runtime-compiler-provenance.sigstore.json + done + cmp secure-runtime-qualification/pulse-agent-linux-amd64 release-compiled/payload/binaries/pulse-agent-linux-amd64 + cmp secure-runtime-qualification/pulse-agent-helper-linux-amd64 release-compiled/payload/binaries/pulse-agent-helper-linux-amd64 + cmp secure-runtime-qualification/pulse-agent-runner-linux-amd64 release-compiled/payload/binaries/pulse-agent-runner-linux-amd64 + - name: Verify exact-SHA compiled payload env: EXPECTED_ARTIFACT_ID: ${{ needs.obtain-release-payload.outputs.artifact_id }} @@ -789,6 +862,8 @@ jobs: PULSE_REQUIRE_WINDOWS_SIGNING: ${{ inputs.require_windows_signing }} PULSE_AGENT_NATIVE_BINARIES_DIR: ${{ (inputs.require_macos_signing || inputs.require_windows_signing) && format('{0}/native-agent-binaries', github.workspace) || '' }} PULSE_RELEASE_COMPILED_PAYLOAD_DIR: ${{ github.workspace }}/release-compiled/payload + PULSE_SECURE_RUNTIME_QUALIFICATION_DIR: ${{ github.workspace }}/secure-runtime-qualification + PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION: "true" VERSION: ${{ inputs.version }} - name: Validate installer signing key pins @@ -816,6 +891,7 @@ jobs: - name: Validate complete candidate locally env: + PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION: "true" VERSION: ${{ inputs.version }} run: ./scripts/validate-release.sh "${VERSION}" --skip-docker diff --git a/.github/workflows/compile-release-payload.yml b/.github/workflows/compile-release-payload.yml index 3aaec26b5..eb7726a69 100644 --- a/.github/workflows/compile-release-payload.yml +++ b/.github/workflows/compile-release-payload.yml @@ -99,3 +99,76 @@ jobs: retention-days: 1 compression-level: 0 overwrite: true + + compile-secure-runtime-qualification: + name: Compile Hosted Secure Runtime Qualification Packet + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + attestations: write + contents: read + id-token: write + steps: + - name: Validate isolated compiler request + env: + EXPECTED_SOURCE_SHA: ${{ inputs.source_sha }} + REQUEST_ID: ${{ inputs.request_id }} + run: | + set -euo pipefail + [[ "${EXPECTED_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]] + [[ "${REQUEST_ID}" =~ ^[0-9]+-[0-9]+$ ]] + test "${GITHUB_SHA}" = "${EXPECTED_SOURCE_SHA}" + test "${GITHUB_WORKFLOW_SHA}" = "${EXPECTED_SOURCE_SHA}" + + - name: Checkout exact release source + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ inputs.source_sha }} + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + + - name: Build hosted qualification subjects + env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + test "$(tr -d '\n' < VERSION)" = "${VERSION}" + test "$(git rev-parse HEAD)" = "${{ inputs.source_sha }}" + ./scripts/build-secure-runtime-qualification.sh \ + "${VERSION}" \ + "$RUNNER_TEMP/secure-runtime-qualification" \ + amd64 + + - name: Attest hosted qualification subjects + id: attest_secure_runtime + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-checksums: ${{ runner.temp }}/secure-runtime-qualification/secure-runtime-compiler-subjects.sha256 + + - name: Preserve portable compiler provenance + env: + PROVENANCE_BUNDLE: ${{ steps.attest_secure_runtime.outputs.bundle-path }} + run: | + set -euo pipefail + test -s "${PROVENANCE_BUNDLE}" + jq -e 'type == "object"' "${PROVENANCE_BUNDLE}" >/dev/null + install -m 0644 \ + "${PROVENANCE_BUNDLE}" \ + "$RUNNER_TEMP/secure-runtime-qualification/secure-runtime-compiler-provenance.sigstore.json" + + - name: Upload hosted qualification packet + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: secure-runtime-qualification-${{ inputs.source_sha }}-${{ inputs.version }}-${{ inputs.request_id }} + path: ${{ runner.temp }}/secure-runtime-qualification/ + if-no-files-found: error + retention-days: 1 + compression-level: 0 + overwrite: true diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 49b84b087..34e1c04d9 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -1163,6 +1163,14 @@ jobs: "${TAG}" \ release/release-build-provenance.sigstore.json \ --clobber + release_upload_with_retry \ + "${TAG}" \ + release/secure-runtime-build-contract-v1.json \ + release/secure-runtime-compiler-provenance.sigstore.json \ + release/pulse-secure-runtime-collector-v1-linux-amd64 \ + release/pulse-secure-runtime-collector-v2-linux-amd64 \ + release/pulse-secure-runtime-collector-v3-linux-amd64 \ + --clobber for bare_agent in \ release/pulse-agent-linux-amd64 \ release/pulse-agent-linux-arm64 \ @@ -1628,8 +1636,11 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: - actions: read + actions: write contents: write + outputs: + secure_runtime_qualification_run_id: ${{ steps.secure_runtime_qualification.outputs.run_id }} + secure_runtime_qualification_run_url: ${{ steps.secure_runtime_qualification.outputs.run_url }} steps: - name: Checkout release integrity control uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -1946,6 +1957,39 @@ jobs: rm -rf "$activation_marker_dir" echo "[OK] Immutably committed, attested, and publicly verified ${TAG}; convergence run ${CONVERGENCE_RUN_ID} owns customer rollout." + # A release published with GITHUB_TOKEN does not emit a workflow-triggering + # release event. Dispatch the qualification explicitly after the immutable + # packet and activation marker have both been verified. + - name: Dispatch exact RC secure-runtime qualification + id: secure_runtime_qualification + if: ${{ needs.prepare.outputs.is_prerelease == 'true' && contains(needs.prepare.outputs.version, '-rc.') }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -euo pipefail + [[ "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]] + dispatch="$( + jq -n \ + --arg tag "${TAG}" \ + '{ref: "main", return_run_details: true, inputs: {tag: $tag}}' | \ + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/qualify-secure-runtime-release.yml/dispatches" \ + --input - + )" + run_id="$(jq -r '.workflow_run_id // empty' <<<"${dispatch}")" + run_url="$(jq -r '.html_url // empty' <<<"${dispatch}")" + if [[ ! "${run_id}" =~ ^[0-9]+$ ]] || [[ -z "${run_url}" ]]; then + echo "::error::Secure-runtime qualification dispatch did not return an exact workflow run." + exit 1 + fi + echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" + echo "run_url=${run_url}" >> "$GITHUB_OUTPUT" + echo "[OK] Secure-runtime qualification is durably queued as ${run_url}." + release_commit_verdict: name: Release Activation Commit Verdict needs: @@ -1991,6 +2035,8 @@ jobs: CONVERGENCE_DISPATCH_RESULT: ${{ needs.dispatch_release_convergence.result }} CONVERGENCE_RUN_ID: ${{ needs.dispatch_release_convergence.outputs.run_id }} CONVERGENCE_RUN_URL: ${{ needs.dispatch_release_convergence.outputs.run_url }} + SECURE_RUNTIME_QUALIFICATION_RUN_ID: ${{ needs.activate_release.outputs.secure_runtime_qualification_run_id }} + SECURE_RUNTIME_QUALIFICATION_RUN_URL: ${{ needs.activate_release.outputs.secure_runtime_qualification_run_url }} R2_PREFIX: ${{ needs.stage_private_pro_runtime.outputs.r2_prefix }} SERVER_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.server_digest }} CONTROL_PLANE_IMAGE_DIGEST: ${{ needs.publish_docker.outputs.control_plane_digest }} @@ -2019,6 +2065,14 @@ jobs: require_result "Helm staging" "$HELM_RESULT" success require_result "immutable release readiness" "$READINESS_RESULT" success require_result "durable customer convergence dispatch" "$CONVERGENCE_DISPATCH_RESULT" success + if [[ "$VERSION" =~ -rc\.[1-9][0-9]*$ ]]; then + if [[ ! "$SECURE_RUNTIME_QUALIFICATION_RUN_ID" =~ ^[0-9]+$ ]] || \ + [[ ! "$SECURE_RUNTIME_QUALIFICATION_RUN_URL" =~ ^https://github\.com/${GITHUB_REPOSITORY}/actions/runs/[0-9]+$ ]]; then + echo "::error::Immutable RC publication did not retain an exact secure-runtime qualification run identity." + exit 1 + fi + echo "[OK] Secure-runtime qualification run: ${SECURE_RUNTIME_QUALIFICATION_RUN_URL}" + fi if [[ "$VERSION" == 6.* ]]; then require_result "private Pro staging" "$PRIVATE_PRO_STAGE_RESULT" success fi diff --git a/.github/workflows/qualify-secure-runtime-release.yml b/.github/workflows/qualify-secure-runtime-release.yml new file mode 100644 index 000000000..013598626 --- /dev/null +++ b/.github/workflows/qualify-secure-runtime-release.yml @@ -0,0 +1,245 @@ +name: Qualify Secure Runtime Release Candidate + +on: + workflow_dispatch: + inputs: + tag: + description: 'Immutable vX.Y.Z-rc.N release tag to qualify' + required: true + type: string + +permissions: + attestations: read + contents: read + +jobs: + qualify-secure-runtime-release: + name: Qualify Immutable RC on Disposable systemd + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - name: Resolve immutable RC identity + id: release + env: + GH_TOKEN: ${{ github.token }} + INPUT_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + tag="${INPUT_TAG:-}" + [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[1-9][0-9]*$ ]] || { + echo "::error::Secure-runtime release qualification requires an exact vX.Y.Z-rc.N tag." + exit 1 + } + release_json="$RUNNER_TEMP/secure-runtime-release.json" + for attempt in $(seq 1 30); do + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" > "${release_json}" + if jq -e --arg tag "${tag}" '.tag_name == $tag and .draft == false and .prerelease == true and .immutable == true' "${release_json}" >/dev/null; then + break + fi + if [[ "${attempt}" == 30 ]]; then + jq -c '{tag_name, draft, prerelease, immutable}' "${release_json}" >&2 + echo "::error::Release did not reach immutable prerelease state." + exit 1 + fi + sleep 10 + done + release_id="$(jq -er '.id | select(type == "number" and . > 0)' "${release_json}")" + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "release_id=${release_id}" >> "$GITHUB_OUTPUT" + + - name: Checkout exact release tag + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + fetch-depth: 0 + ref: ${{ steps.release.outputs.tag }} + + - name: Verify detached release source + id: source + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main + commit="$(git rev-parse "${TAG}^{commit}")" + test "$(git branch --show-current)" = "" + test -z "$(git status --short --untracked-files=all)" + git merge-base --is-ancestor "${commit}" origin/main + echo "commit=${commit}" >> "$GITHUB_OUTPUT" + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + + - name: Download exact immutable qualification packet + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/secure-runtime-release" "$RUNNER_TEMP/secure-runtime-evidence" + for asset in \ + checksums.txt \ + release-build-provenance.sigstore.json \ + secure-runtime-compiler-provenance.sigstore.json \ + secure-runtime-build-contract-v1.json \ + pulse-secure-runtime-collector-v1-linux-amd64 \ + pulse-secure-runtime-collector-v1-linux-amd64.sig \ + pulse-secure-runtime-collector-v2-linux-amd64 \ + pulse-secure-runtime-collector-v2-linux-amd64.sig \ + pulse-secure-runtime-collector-v3-linux-amd64 \ + pulse-secure-runtime-collector-v3-linux-amd64.sig \ + pulse-agent-linux-amd64 \ + pulse-agent-linux-amd64.sig \ + pulse-agent-helper-linux-amd64 \ + pulse-agent-runner-linux-amd64; do + gh release download "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --dir "$RUNNER_TEMP/secure-runtime-release" \ + --pattern "${asset}" + done + chmod 0755 \ + "$RUNNER_TEMP/secure-runtime-release"/pulse-secure-runtime-collector-v*-linux-amd64 \ + "$RUNNER_TEMP/secure-runtime-release"/pulse-agent-linux-amd64 \ + "$RUNNER_TEMP/secure-runtime-release"/pulse-agent-helper-linux-amd64 \ + "$RUNNER_TEMP/secure-runtime-release"/pulse-agent-runner-linux-amd64 + + - name: Build exact-source qualification harness + run: | + set -euo pipefail + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ + go test -c \ + -o "$RUNNER_TEMP/secure-runtime-release/installtests-linux-amd64.test" \ + ./scripts/installtests + + - name: Build disposable systemd host image + run: | + set -euo pipefail + context="$RUNNER_TEMP/secure-runtime-systemd-image" + mkdir -p "${context}/empty" + tee "${context}/Dockerfile" >/dev/null <<'DOCKERFILE' + FROM ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 + ENV container=docker + RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl dbus systemd systemd-sysv util-linux && \ + apt-get clean && \ + systemctl mask dev-hugepages.mount sys-fs-fuse-connections.mount systemd-remount-fs.service + STOPSIGNAL SIGRTMIN+3 + CMD ["/sbin/init"] + DOCKERFILE + docker build \ + --tag pulse-secure-runtime-systemd:ubuntu-24.04 \ + --file "${context}/Dockerfile" \ + "${context}/empty" + + - name: Run schema-v6 systemd qualification + id: lab + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + container="pulse-secure-runtime-qualification-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + echo "container=${container}" >> "$GITHUB_OUTPUT" + docker run \ + --detach \ + --name "${container}" \ + --privileged \ + --cgroupns=host \ + --tmpfs /run \ + --tmpfs /run/lock \ + --volume /sys/fs/cgroup:/sys/fs/cgroup:rw \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$RUNNER_TEMP/secure-runtime-release:/release:ro" \ + --volume "$RUNNER_TEMP/secure-runtime-evidence:/evidence:rw" \ + pulse-secure-runtime-systemd:ubuntu-24.04 + for attempt in $(seq 1 30); do + if docker exec "${container}" systemctl show-environment >/dev/null 2>&1; then + break + fi + if [[ "${attempt}" == 30 ]]; then + docker logs "${container}" + exit 1 + fi + sleep 2 + done + docker exec "${container}" sh -c \ + 'for command in curl id nsenter runuser systemctl; do command -v "$command" >/dev/null || exit 1; done' + docker exec "${container}" sh -c \ + 'printf "%s\n" "PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1" > /etc/pulse-secure-runtime-lab' + start_epoch="$(date +%s)" + docker exec \ + --env PULSE_SECURE_RUNTIME_SYSTEMD_LAB=1 \ + --env PULSE_SECURE_RUNTIME_COLLECTOR_V1=/release/pulse-secure-runtime-collector-v1-linux-amd64 \ + --env PULSE_SECURE_RUNTIME_COLLECTOR_V1_SIGNATURE=/release/pulse-secure-runtime-collector-v1-linux-amd64.sig \ + --env PULSE_SECURE_RUNTIME_COLLECTOR_V2=/release/pulse-secure-runtime-collector-v2-linux-amd64 \ + --env PULSE_SECURE_RUNTIME_COLLECTOR_V2_SIGNATURE=/release/pulse-secure-runtime-collector-v2-linux-amd64.sig \ + --env PULSE_SECURE_RUNTIME_COLLECTOR_V3=/release/pulse-secure-runtime-collector-v3-linux-amd64 \ + --env PULSE_SECURE_RUNTIME_COLLECTOR_V3_SIGNATURE=/release/pulse-secure-runtime-collector-v3-linux-amd64.sig \ + --env PULSE_SECURE_RUNTIME_COLLECTOR_V4=/release/pulse-agent-linux-amd64 \ + --env PULSE_SECURE_RUNTIME_COLLECTOR_V4_SIGNATURE=/release/pulse-agent-linux-amd64.sig \ + --env PULSE_SECURE_RUNTIME_HELPER=/release/pulse-agent-helper-linux-amd64 \ + --env PULSE_SECURE_RUNTIME_RUNNER=/release/pulse-agent-runner-linux-amd64 \ + --env PULSE_SECURE_RUNTIME_RECEIPT=/evidence/receipt.json \ + --env "PULSE_SECURE_RUNTIME_RECEIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v6-${TAG}.json" \ + --env PULSE_SECURE_RUNTIME_TRANSCRIPT=/evidence/transcript.jsonl \ + --env "PULSE_SECURE_RUNTIME_TRANSCRIPT_RECORD_PATH=docs/release-control/v6/internal/records/secure-agent-runtime-systemd-transcript-v6-${TAG}.jsonl" \ + "${container}" \ + sh -c 'cd /workspace/scripts/installtests && exec /release/installtests-linux-amd64.test -test.run "^TestSecureRuntimeSystemdLab$" -test.count=1 -test.v' + elapsed="$(( $(date +%s) - start_epoch ))" + if [[ "${elapsed}" -lt 1 ]]; then elapsed=1; fi + echo "elapsed_seconds=${elapsed}" >> "$GITHUB_OUTPUT" + + - name: Create release-candidate secure-runtime attestation + env: + GH_TOKEN: ${{ github.token }} + QUALIFIED_COMMIT: ${{ steps.source.outputs.commit }} + RELEASE_ID: ${{ steps.release.outputs.release_id }} + TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + release_dir="$RUNNER_TEMP/secure-runtime-release" + evidence_dir="$RUNNER_TEMP/secure-runtime-evidence" + update_fingerprint="$(jq -er '.update_key_fingerprint' "${release_dir}/secure-runtime-build-contract-v1.json")" + python3 scripts/release_control/secure_runtime_attestation_v6.py \ + --checkout "${GITHUB_WORKSPACE}" \ + --commit "${QUALIFIED_COMMIT}" \ + --main-ref origin/main \ + --receipt "${evidence_dir}/receipt.json" \ + --receipt-record-path "docs/release-control/v6/internal/records/secure-agent-runtime-systemd-receipt-v6-${TAG}.json" \ + --transcript "${evidence_dir}/transcript.jsonl" \ + --collector-v1 "${release_dir}/pulse-secure-runtime-collector-v1-linux-amd64" \ + --collector-v2 "${release_dir}/pulse-secure-runtime-collector-v2-linux-amd64" \ + --collector-v3 "${release_dir}/pulse-secure-runtime-collector-v3-linux-amd64" \ + --collector-v4 "${release_dir}/pulse-agent-linux-amd64" \ + --helper "${release_dir}/pulse-agent-helper-linux-amd64" \ + --runner "${release_dir}/pulse-agent-runner-linux-amd64" \ + --collector-v1-signature "${release_dir}/pulse-secure-runtime-collector-v1-linux-amd64.sig" \ + --collector-v2-signature "${release_dir}/pulse-secure-runtime-collector-v2-linux-amd64.sig" \ + --collector-v3-signature "${release_dir}/pulse-secure-runtime-collector-v3-linux-amd64.sig" \ + --collector-v4-signature "${release_dir}/pulse-agent-linux-amd64.sig" \ + --elapsed-seconds "${{ steps.lab.outputs.elapsed_seconds }}" \ + --release-candidate-tag "${TAG}" \ + --release-repository "${GITHUB_REPOSITORY}" \ + --release-id "${RELEASE_ID}" \ + --release-checksums "${release_dir}/checksums.txt" \ + --release-assembly-provenance "${release_dir}/release-build-provenance.sigstore.json" \ + --release-compiler-provenance "${release_dir}/secure-runtime-compiler-provenance.sigstore.json" \ + --release-build-contract "${release_dir}/secure-runtime-build-contract-v1.json" \ + --expected-release-update-key-fingerprint "${update_fingerprint}" \ + --output "${evidence_dir}/attestation.json" + + - name: Stop disposable systemd host + if: ${{ always() && steps.lab.outputs.container != '' }} + run: docker rm --force "${{ steps.lab.outputs.container }}" + + - name: Upload immutable RC qualification evidence + if: ${{ success() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: secure-runtime-qualification-${{ steps.release.outputs.tag }}-${{ steps.source.outputs.commit }} + path: ${{ runner.temp }}/secure-runtime-evidence/ + if-no-files-found: error + retention-days: 90 + compression-level: 0 diff --git a/docs/AGENT_SECURITY.md b/docs/AGENT_SECURITY.md index 3c0407a5d..daa925262 100644 --- a/docs/AGENT_SECURITY.md +++ b/docs/AGENT_SECURITY.md @@ -204,9 +204,14 @@ an immutable signed GitHub Release packet, release-attested checksums, trusted hosted-builder SLSA provenance, and a signed build contract that binds every qualification artifact to its package, toolchain, exact build settings and ldflags, version, production update-key fingerprint, and release checksum. -The current release workflow does not yet emit that complete secure-runtime -build contract or the four-version qualification artifact set, so a local tag -or VCS-stamped lab binary cannot upgrade the proof to RC status. +The release workflow now builds that four-version Linux amd64 qualification +set on a separate GitHub-hosted compiler job, verifies the current collector, +helper, and runner against the ordinary release bytes, publishes compiler +provenance and the signed build contract, and automatically runs the guarded +systemd lab after an immutable prerelease is published. No release candidate +containing this wiring has been published yet, so current evidence remains +committed-main proof; a local tag or VCS-stamped lab binary still cannot +upgrade it to RC status. The typed-helper profile cannot be combined with `--grant-smart` or `--grant-pct`. The collector never joins the rootful Docker group. When no diff --git a/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md b/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md index d2c4f98ca..6f6017873 100644 --- a/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md +++ b/docs/release-control/v6/internal/AGENT_PRIVILEGE_BOUNDARY_PLAN.md @@ -206,12 +206,17 @@ artifact to the checksums and record both workflow identities, a GitHub-hosted-only compiler policy, the source commit, Go toolchain, package, target, `CGO_ENABLED`, `-trimpath`, `-buildvcs=false`, exact ldflags, version, and production update-key fingerprint. Hosted assembly of a payload emitted by -a self-hosted compiler is not trusted compilation provenance. The current -compiler workflow is self-hosted and the release workflow does not yet publish -the compiler provenance, secure-runtime build contract, or full multi-version -qualification artifact set. Therefore release-candidate classification fails -closed, and current local lab evidence remains committed-main, artifact-bound, -and self-attested even if a local tag exists. +a self-hosted compiler is not trusted compilation provenance. The canonical +compiler workflow now has a separate GitHub-hosted qualification job that +builds and attests the six Linux amd64 subjects without signing secrets. Hosted +candidate assembly requires its current collector, helper, and runner to match +the ordinary release payload byte for byte, then publishes the three predecessor +collectors, compiler provenance, and build contract through the normal signed +release packet. A published prerelease automatically runs the twenty-scenario +lab on a disposable systemd host and invokes this verifier with all four +release signatures. No qualifying RC containing this wiring has been published +yet, so current evidence remains committed-main, artifact-bound, and +self-attested; a local tag still cannot upgrade it to RC status. Schema-v6 committed-main classification is likewise not caller-relative. The attester accepts only `origin/main`, requires the canonical Pulse origin URL, diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index c1a8e3a3d..8c860f86f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -225,6 +225,25 @@ Release builds and archives carry both helper and runner binaries for the five Linux targets (`amd64`, `arm64`, `armv7`, `armv6`, and `386`) with checksum, Ed25519, and SSH signature sidecars. Exact archive/container-context validation must prove those assets rather than inferring them from collector packaging. +Every canonical candidate also carries one Linux amd64 secure-runtime +qualification packet compiled on a GitHub-hosted runner. That compiler emits +three version-distinct predecessor collectors plus byte-for-byte +reproductions of the ordinary release collector, helper, and runner, a closed +build contract, and portable SLSA compiler provenance. Hosted candidate +assembly rejects the packet unless those three current binaries reproduce the +ordinary payload exactly, then publishes only the predecessor collectors next +to the ordinary current binaries and binds the compiler provenance and build +contract through the normal SBOM, checksum, Ed25519, SSH-signature, immutable +manifest, and assembly-provenance path. The update signing private key remains +absent from compilation and is used only by hosted candidate assembly. +Published prerelease packets automatically enter the separate disposable +Ubuntu/systemd qualification workflow. That workflow consumes the immutable +release assets and four release signatures, runs the canonical twenty-scenario +schema-v6 lab, and applies the release-candidate attester against the exact +GitHub release ID, tag, source commit, checksums, compiler provenance, assembly +provenance, and update-key fingerprint. Its retained receipt remains explicit +self-attestation rather than external security review, and successful packet +production or execution does not change the safe profile from opt-in. This packaging proof does not establish live platform qualification: helper update staging/activation/restart/rollback on a real systemd host, real Docker/Podman and Proxmox action execution, systemd migration rehearsal, and @@ -291,6 +310,8 @@ release-latency optimization. 16. `internal/cloudcp/docker/labels.go` 17. `internal/cloudcp/tenant_runtime_rollout.go` 13. `.github/workflows/build-release-candidate.yml` +13a. `.github/workflows/compile-release-payload.yml` +13b. `.github/workflows/qualify-secure-runtime-release.yml` 14. `.github/workflows/build-and-test.yml` 14. `.github/workflows/create-release.yml` 14. `.github/workflows/deploy-demo-server.yml` @@ -338,6 +359,7 @@ release-latency optimization. 35. `go.sum` 36. `scripts/build-release.sh` 37. `scripts/build-release-binaries.sh` +37a. `scripts/build-secure-runtime-qualification.sh` 38. `scripts/release_build_targets.sh` 39. `scripts/run-release-backend-tests.sh` 40. `scripts/shard_go_tests.py` @@ -374,7 +396,10 @@ release-latency optimization. 63. `scripts/release_control/live_runtime_proof_test.py` 64. `scripts/release_candidate_manifest.py` 65. `scripts/release_control/validate_artifact_release_line.py` +65a. `scripts/release_control/secure_runtime_attestation_v6.py` +65b. `scripts/release_control/secure_runtime_source_manifest_v6.json` 66. `scripts/release_ldflags.sh` +66a. `scripts/release_update_key.go` 67. `scripts/run_cloud_public_signup_smoke.sh` 68. `scripts/run_demo_public_browser_smoke.sh` 69. `scripts/demo_public_browser_smoke.cjs` @@ -744,7 +769,7 @@ artifact-selection behaviour. ## Extension Points 1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/` -2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, model-selected visual release-note capture, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/build-release-binaries.sh`, `scripts/release_build_targets.sh`, `scripts/run-release-backend-tests.sh`, `scripts/shard_go_tests.py`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/capture-release-note-visuals.sh`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/verify-github-release-integrity.sh`, `scripts/verify-release-container-images.sh`, `scripts/release_control/verify_release_container_images_test.py`, `scripts/release_control/capture_release_note_visuals.mjs`, `scripts/release_control/release_note_visuals.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `pulse-enterprise:scripts/build-pro-binaries.sh`, `pulse-enterprise:scripts/build-pro-release.sh`, `pulse-enterprise:scripts/validate-pro-release-line.sh`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/build-release-candidate.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/recover-release-activation.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/workflows/validate-release-assets.yml`, and `pulse-enterprise:.github/workflows/build-pro-release.yml` +2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, hosted secure-runtime qualification compilation and attestation, governed promotion metadata resolution, artifact release-line validation, post-install live-runtime claim proof, the canonical version file, operator-facing release packet content, model-selected visual release-note capture, prerelease feedback intake wording, historical published-release integrity backfill, release asset validation status publication, download endpoint checksum/signature header proof, end-to-end install.sh smoke against staged or published release assets, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/build-release-binaries.sh`, `scripts/build-secure-runtime-qualification.sh`, `scripts/release_build_targets.sh`, `scripts/run-release-backend-tests.sh`, `scripts/shard_go_tests.py`, `scripts/release_asset_common.sh`, `scripts/backfill-release-assets.sh`, `scripts/release_ldflags.sh`, `scripts/release_update_key.go`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/capture-release-note-visuals.sh`, `scripts/release-preflight-worker.sh`, `scripts/run-release-preflight.sh`, `scripts/verify-github-release-integrity.sh`, `scripts/verify-release-container-images.sh`, `scripts/release_control/secure_runtime_attestation_v6.py`, `scripts/release_control/secure_runtime_source_manifest_v6.json`, `scripts/release_control/verify_release_container_images_test.py`, `scripts/release_control/capture_release_note_visuals.mjs`, `scripts/release_control/release_note_visuals.py`, `scripts/release_control/live_runtime_proof.py`, `scripts/release_control/live_runtime_proof_test.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `pulse-enterprise:scripts/build-pro-binaries.sh`, `pulse-enterprise:scripts/build-pro-release.sh`, `pulse-enterprise:scripts/validate-pro-release-line.sh`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/validate-release.sh`, `scripts/validate-published-release.sh`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/backfill-release-assets.yml`, `.github/workflows/build-release-candidate.yml`, `.github/workflows/compile-release-payload.yml`, `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/install-sh-smoke.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/promote-private-pro-runtime.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/qualify-secure-runtime-release.yml`, `.github/workflows/recover-release-activation.yml`, `.github/workflows/release-convergence.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/retry-release-convergence.yml`, `.github/workflows/update-demo-server.yml`, `.github/workflows/validate-release-assets.yml`, and `pulse-enterprise:.github/workflows/build-pro-release.yml` The governed release-build surface also includes `scripts/prepare-release-container-context.sh` for exact-candidate container assembly. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b2a631a33..063972f2f 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4375,6 +4375,7 @@ ".github/workflows/backfill-release-assets.yml", ".github/workflows/build-and-test.yml", ".github/workflows/build-release-candidate.yml", + ".github/workflows/compile-release-payload.yml", ".github/workflows/create-release.yml", ".github/workflows/deploy-demo-server.yml", ".github/workflows/helm-pages.yml", @@ -4383,6 +4384,7 @@ ".github/workflows/publish-docker.yml", ".github/workflows/publish-helm-chart.yml", ".github/workflows/qualify-release-containers.yml", + ".github/workflows/qualify-secure-runtime-release.yml", ".github/workflows/recover-demo-server.yml", ".github/workflows/recover-release-activation.yml", ".github/workflows/release-convergence.yml", @@ -4438,6 +4440,7 @@ "scripts/backfill-release-assets.sh", "scripts/build-release-binaries.sh", "scripts/build-release.sh", + "scripts/build-secure-runtime-qualification.sh", "scripts/check-workflow-dispatch-inputs.py", "scripts/clean-mock-alerts.sh", "scripts/com.pulse.hot-dev.plist.template", @@ -4469,8 +4472,11 @@ "scripts/release_control/release_promotion_policy_support.py", "scripts/release_control/render_release_body.py", "scripts/release_control/resolve_release_promotion.py", + "scripts/release_control/secure_runtime_attestation_v6.py", + "scripts/release_control/secure_runtime_source_manifest_v6.json", "scripts/release_control/validate_artifact_release_line.py", "scripts/release_ldflags.sh", + "scripts/release_update_key.go", "scripts/run-release-backend-tests.sh", "scripts/run-release-preflight.sh", "scripts/run_cloud_public_signup_smoke.sh", @@ -4584,6 +4590,7 @@ ".github/scripts/setup-demo-ssh.sh", ".github/workflows/backfill-release-assets.yml", ".github/workflows/build-release-candidate.yml", + ".github/workflows/compile-release-payload.yml", ".github/workflows/create-release.yml", ".github/workflows/helm-pages.yml", ".github/workflows/install-sh-smoke.yml", @@ -4592,6 +4599,7 @@ ".github/workflows/publish-docker.yml", ".github/workflows/publish-helm-chart.yml", ".github/workflows/qualify-release-containers.yml", + ".github/workflows/qualify-secure-runtime-release.yml", ".github/workflows/recover-demo-server.yml", ".github/workflows/recover-release-activation.yml", ".github/workflows/release-convergence.yml", @@ -4680,10 +4688,14 @@ "scripts/backfill-release-assets.sh", "scripts/build-release-binaries.sh", "scripts/build-release.sh", + "scripts/build-secure-runtime-qualification.sh", "scripts/prepare-release-container-context.sh", "scripts/release_asset_common.sh", "scripts/release_build_targets.sh", + "scripts/release_control/secure_runtime_attestation_v6.py", + "scripts/release_control/secure_runtime_source_manifest_v6.json", "scripts/release_ldflags.sh", + "scripts/release_update_key.go", "scripts/validate-release.sh" ], "allow_same_subsystem_tests": false, @@ -4693,7 +4705,8 @@ "scripts/installtests/backfill_release_assets_test.go", "scripts/installtests/build_release_assets_test.go", "scripts/installtests/release_ldflags_test.go", - "scripts/installtests/safe_profile_migration_test.go" + "scripts/installtests/safe_profile_migration_test.go", + "scripts/release_control/secure_runtime_attestation_v6_test.py" ] }, { diff --git a/frontend-modern/public/docs/AGENT_SECURITY.md b/frontend-modern/public/docs/AGENT_SECURITY.md index 3c0407a5d..daa925262 100644 --- a/frontend-modern/public/docs/AGENT_SECURITY.md +++ b/frontend-modern/public/docs/AGENT_SECURITY.md @@ -204,9 +204,14 @@ an immutable signed GitHub Release packet, release-attested checksums, trusted hosted-builder SLSA provenance, and a signed build contract that binds every qualification artifact to its package, toolchain, exact build settings and ldflags, version, production update-key fingerprint, and release checksum. -The current release workflow does not yet emit that complete secure-runtime -build contract or the four-version qualification artifact set, so a local tag -or VCS-stamped lab binary cannot upgrade the proof to RC status. +The release workflow now builds that four-version Linux amd64 qualification +set on a separate GitHub-hosted compiler job, verifies the current collector, +helper, and runner against the ordinary release bytes, publishes compiler +provenance and the signed build contract, and automatically runs the guarded +systemd lab after an immutable prerelease is published. No release candidate +containing this wiring has been published yet, so current evidence remains +committed-main proof; a local tag or VCS-stamped lab binary still cannot +upgrade it to RC status. The typed-helper profile cannot be combined with `--grant-smart` or `--grant-pct`. The collector never joins the rootful Docker group. When no diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 102ff0d24..30295db73 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -559,6 +559,65 @@ sed -i "s|^CP_PULSE_IMAGE=.*|CP_PULSE_IMAGE=ghcr.io/rcourtman/pulse:v${VERSION}| printf '%s\n' "${VERSION}" > "${provider_msp_bundle_dir}/VERSION" tar -czf "${provider_msp_bundle_asset}" -C "${BUILD_DIR}" "${provider_msp_bundle_root}" +# Import the small GitHub-hosted secure-runtime qualification packet before +# SBOM, checksum, and signature generation. The ordinary release payload stays +# canonical for current binaries: hosted compiler output is accepted only when +# collector-v4, helper, and runner reproduce those exact bytes. +if [[ -n "${PULSE_SECURE_RUNTIME_QUALIFICATION_DIR:-}" ]]; then + qualification_dir="$(cd "${PULSE_SECURE_RUNTIME_QUALIFICATION_DIR}" && pwd)" + qualification_expected=( + pulse-secure-runtime-collector-v1-linux-amd64 + pulse-secure-runtime-collector-v2-linux-amd64 + pulse-secure-runtime-collector-v3-linux-amd64 + pulse-agent-linux-amd64 + pulse-agent-helper-linux-amd64 + pulse-agent-runner-linux-amd64 + secure-runtime-build-contract-v1.json + secure-runtime-compiler-provenance.sigstore.json + secure-runtime-compiler-subjects.sha256 + ) + mapfile -t qualification_actual < <(find "${qualification_dir}" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort) + mapfile -t qualification_expected_sorted < <(printf '%s\n' "${qualification_expected[@]}" | sort) + if [[ "$(printf '%s\n' "${qualification_actual[@]}")" != "$(printf '%s\n' "${qualification_expected_sorted[@]}")" ]]; then + echo "Error: hosted secure-runtime qualification packet has an unexpected file set." >&2 + printf 'Expected:\n%s\nActual:\n%s\n' \ + "$(printf '%s\n' "${qualification_expected_sorted[@]}")" \ + "$(printf '%s\n' "${qualification_actual[@]}")" >&2 + exit 1 + fi + for qualification_name in "${qualification_expected[@]}"; do + if [[ ! -f "${qualification_dir}/${qualification_name}" || -L "${qualification_dir}/${qualification_name}" ]]; then + echo "Error: hosted secure-runtime qualification subject is not a regular non-symlink file: ${qualification_name}" >&2 + exit 1 + fi + done + cmp "${qualification_dir}/pulse-agent-linux-amd64" "${BUILD_DIR}/pulse-agent-linux-amd64" || { + echo "Error: hosted secure-runtime collector-v4 does not reproduce the release collector." >&2 + exit 1 + } + cmp "${qualification_dir}/pulse-agent-helper-linux-amd64" "${BUILD_DIR}/pulse-agent-helper-linux-amd64" || { + echo "Error: hosted secure-runtime helper does not reproduce the release helper." >&2 + exit 1 + } + cmp "${qualification_dir}/pulse-agent-runner-linux-amd64" "${BUILD_DIR}/pulse-agent-runner-linux-amd64" || { + echo "Error: hosted secure-runtime runner does not reproduce the release runner." >&2 + exit 1 + } + for qualification_name in \ + pulse-secure-runtime-collector-v1-linux-amd64 \ + pulse-secure-runtime-collector-v2-linux-amd64 \ + pulse-secure-runtime-collector-v3-linux-amd64 \ + secure-runtime-build-contract-v1.json \ + secure-runtime-compiler-provenance.sigstore.json; do + install -m 0644 "${qualification_dir}/${qualification_name}" "${RELEASE_DIR}/${qualification_name}" + done + chmod 0755 "${RELEASE_DIR}"/pulse-secure-runtime-collector-v*-linux-amd64 + echo "Imported hosted secure-runtime qualification packet." +elif [[ "${PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION:-false}" == "true" ]]; then + echo "Error: release requires the hosted secure-runtime qualification packet." >&2 + exit 1 +fi + pulse_release_generate_packet_sbom "${RELEASE_DIR}" "${RELEASE_PACKET_SBOM}" mapfile -t checksum_files < <(pulse_release_collect_checksum_files "${RELEASE_DIR}") pulse_release_write_checksums_and_signatures "${RELEASE_DIR}" "${checksum_files[@]}" diff --git a/scripts/build-secure-runtime-qualification.sh b/scripts/build-secure-runtime-qualification.sh new file mode 100755 index 000000000..53e448d4b --- /dev/null +++ b/scripts/build-secure-runtime-qualification.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash + +# Build the exact Linux/amd64 secure-runtime qualification subjects on a +# GitHub-hosted runner. Candidate assembly publishes the three predecessor +# collectors and requires the current collector/helper/runner subjects to be +# byte-identical to the ordinary release payload before signing the packet. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" + +VERSION="${1:-}" +OUTPUT_DIR="${2:-}" +TARGET_ARCH="${3:-amd64}" + +if [[ -z "${VERSION}" || -z "${OUTPUT_DIR}" || -n "${4:-}" ]]; then + echo "Usage: $0 [amd64|arm64]" >&2 + exit 2 +fi +case "${TARGET_ARCH}" in + amd64|arm64) ;; + *) + echo "Error: unsupported secure-runtime qualification architecture: ${TARGET_ARCH}" >&2 + exit 2 + ;; +esac +if [[ "$(tr -d '\n\r[:space:]' < VERSION)" != "${VERSION}" ]]; then + echo "Error: requested version ${VERSION} does not match VERSION." >&2 + exit 3 +fi +if [[ -z "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" ]]; then + echo "Error: PULSE_UPDATE_SIGNING_PUBLIC_KEY is required." >&2 + exit 3 +fi + +required_go="go1.26.7" +go_version="$(go env GOVERSION 2>/dev/null || true)" +if [[ "${go_version}" != "${required_go}" ]]; then + echo "Error: Go toolchain must be ${required_go} (got ${go_version:-unknown})." >&2 + exit 3 +fi + +OUTPUT_DIR="$(python3 -c 'import os, sys; print(os.path.abspath(sys.argv[1]))' "${OUTPUT_DIR}")" +case "${OUTPUT_DIR}" in + /|"${REPO_ROOT}"|"${REPO_ROOT}/scripts") + echo "Error: refusing unsafe secure-runtime qualification output: ${OUTPUT_DIR}" >&2 + exit 2 + ;; +esac +if [[ -e "${OUTPUT_DIR}" ]] && find "${OUTPUT_DIR}" -mindepth 1 -print -quit | grep -q .; then + echo "Error: secure-runtime qualification output must be absent or empty: ${OUTPUT_DIR}" >&2 + exit 2 +fi +mkdir -p "${OUTPUT_DIR}" + +source_sha="$(git rev-parse HEAD)" +if [[ ! "${source_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Error: secure-runtime qualification requires an exact Git commit." >&2 + exit 3 +fi + +update_public_key="$(printf '%s' "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" | tr -d '\r\n[:space:]')" +update_key_fingerprint="$(go run ./scripts/release_update_key.go fingerprint --public-key "${update_public_key}")" +release_tag="v${VERSION}" +predecessor_base="${VERSION%%-*}" +collector_v1_version="${predecessor_base}-0.secure.v6.1" +collector_v2_version="${predecessor_base}-0.secure.v6.2" +collector_v3_version="${predecessor_base}-0.secure.v6.3" + +collector_v1_ldflags="$(./scripts/release_ldflags.sh agent --version "${collector_v1_version}" --update-public-keys "${update_public_key}")" +collector_v2_ldflags="$(./scripts/release_ldflags.sh agent --version "${collector_v2_version}" --update-public-keys "${update_public_key}")" +collector_v3_ldflags="$(./scripts/release_ldflags.sh agent --version "${collector_v3_version}" --update-public-keys "${update_public_key}")" +release_agent_ldflags="$(./scripts/release_ldflags.sh agent --version "${VERSION}" --update-public-keys "${update_public_key}")" + +artifact_asset() { + case "$1" in + collector_v1) printf 'pulse-secure-runtime-collector-v1-linux-%s\n' "${TARGET_ARCH}" ;; + collector_v2) printf 'pulse-secure-runtime-collector-v2-linux-%s\n' "${TARGET_ARCH}" ;; + collector_v3) printf 'pulse-secure-runtime-collector-v3-linux-%s\n' "${TARGET_ARCH}" ;; + collector_v4) printf 'pulse-agent-linux-%s\n' "${TARGET_ARCH}" ;; + helper) printf 'pulse-agent-helper-linux-%s\n' "${TARGET_ARCH}" ;; + runner) printf 'pulse-agent-runner-linux-%s\n' "${TARGET_ARCH}" ;; + esac +} + +artifact_package() { + case "$1" in + collector_v1|collector_v2|collector_v3|collector_v4) + printf '%s\n' 'github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent' + ;; + helper) printf '%s\n' 'github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-helper' ;; + runner) printf '%s\n' 'github.com/rcourtman/pulse-go-rewrite/cmd/pulse-agent-runner' ;; + esac +} + +artifact_version() { + case "$1" in + collector_v1) printf '%s\n' "${collector_v1_version}" ;; + collector_v2) printf '%s\n' "${collector_v2_version}" ;; + collector_v3) printf '%s\n' "${collector_v3_version}" ;; + collector_v4|helper|runner) printf '%s\n' "${VERSION}" ;; + esac +} + +artifact_ldflags() { + case "$1" in + collector_v1) printf '%s\n' "${collector_v1_ldflags}" ;; + collector_v2) printf '%s\n' "${collector_v2_ldflags}" ;; + collector_v3) printf '%s\n' "${collector_v3_ldflags}" ;; + collector_v4|helper) printf '%s\n' "${release_agent_ldflags}" ;; + runner) printf '\n' ;; + esac +} + +build_subject() { + local name="$1" + local package="./cmd/pulse-agent" + local output="${OUTPUT_DIR}/$(artifact_asset "${name}")" + local -a command=(go build -buildvcs=false -trimpath) + case "${name}" in + helper) package="./cmd/pulse-agent-helper" ;; + runner) package="./cmd/pulse-agent-runner" ;; + esac + local ldflags="$(artifact_ldflags "${name}")" + if [[ -n "${ldflags}" ]]; then + command+=("-ldflags=${ldflags}") + fi + command+=(-o "${output}" "${package}") + env CGO_ENABLED=0 GOOS=linux GOARCH="${TARGET_ARCH}" "${command[@]}" +} + +for name in collector_v1 collector_v2 collector_v3 collector_v4 helper runner; do + build_subject "${name}" +done + +export SECURE_RUNTIME_OUTPUT_DIR="${OUTPUT_DIR}" +export SECURE_RUNTIME_VERSION="${VERSION}" +export SECURE_RUNTIME_TAG="${release_tag}" +export SECURE_RUNTIME_SOURCE_SHA="${source_sha}" +export SECURE_RUNTIME_TARGET_ARCH="${TARGET_ARCH}" +export SECURE_RUNTIME_GO_VERSION="${go_version}" +export SECURE_RUNTIME_UPDATE_PUBLIC_KEY="${update_public_key}" +export SECURE_RUNTIME_UPDATE_KEY_FINGERPRINT="${update_key_fingerprint}" +for name in collector_v1 collector_v2 collector_v3 collector_v4 helper runner; do + upper_name="$(printf '%s' "${name}" | tr '[:lower:]' '[:upper:]')" + export "SECURE_RUNTIME_${upper_name}_ASSET=$(artifact_asset "${name}")" + export "SECURE_RUNTIME_${upper_name}_PACKAGE=$(artifact_package "${name}")" + export "SECURE_RUNTIME_${upper_name}_VERSION=$(artifact_version "${name}")" + export "SECURE_RUNTIME_${upper_name}_LDFLAGS=$(artifact_ldflags "${name}")" +done + +python3 - <<'PY' +import hashlib +import json +import os +from pathlib import Path + +root = Path(os.environ["SECURE_RUNTIME_OUTPUT_DIR"]) +names = ("collector_v1", "collector_v2", "collector_v3", "collector_v4", "helper", "runner") +artifacts = {} +subject_lines = [] +for name in names: + prefix = f"SECURE_RUNTIME_{name.upper()}_" + asset = os.environ[prefix + "ASSET"] + path = root / asset + digest = hashlib.sha256(path.read_bytes()).hexdigest() + ldflags = os.environ[prefix + "LDFLAGS"] + artifacts[name] = { + "release_asset": asset, + "sha256": digest, + "build": { + "tool": "go build", + "package": os.environ[prefix + "PACKAGE"], + "target_os": "linux", + "target_arch": os.environ["SECURE_RUNTIME_TARGET_ARCH"], + "cgo_enabled": 0, + "go_version": os.environ["SECURE_RUNTIME_GO_VERSION"], + "trimpath": True, + "buildvcs": False, + "build_args": ["-buildvcs=false", "-trimpath"], + "ldflags": ldflags, + "ldflags_sha256": hashlib.sha256(ldflags.encode()).hexdigest(), + "version": os.environ[prefix + "VERSION"], + "update_key_fingerprint": os.environ["SECURE_RUNTIME_UPDATE_KEY_FINGERPRINT"], + }, + } + subject_lines.append(f"{digest} {asset}") + +contract = { + "schema_version": 1, + "repository": "rcourtman/Pulse", + "assembly_signer_workflow": "github.com/rcourtman/Pulse/.github/workflows/build-release-candidate.yml", + "compiler_signer_workflow": "github.com/rcourtman/Pulse/.github/workflows/compile-release-payload.yml", + "compiler_runner_trust": "github-hosted-deny-self-hosted", + "tag": os.environ["SECURE_RUNTIME_TAG"], + "version": os.environ["SECURE_RUNTIME_VERSION"], + "source_sha": os.environ["SECURE_RUNTIME_SOURCE_SHA"], + "update_public_keys": os.environ["SECURE_RUNTIME_UPDATE_PUBLIC_KEY"], + "update_key_fingerprint": os.environ["SECURE_RUNTIME_UPDATE_KEY_FINGERPRINT"], + "artifacts": artifacts, +} +(root / "secure-runtime-build-contract-v1.json").write_text( + json.dumps(contract, indent=2, sort_keys=True) + "\n", encoding="utf-8" +) +contract_digest = hashlib.sha256( + (root / "secure-runtime-build-contract-v1.json").read_bytes() +).hexdigest() +subject_lines.append(f"{contract_digest} secure-runtime-build-contract-v1.json") +(root / "secure-runtime-compiler-subjects.sha256").write_text( + "\n".join(subject_lines) + "\n", encoding="utf-8" +) +PY + +echo "Built hosted secure-runtime qualification subjects in ${OUTPUT_DIR}." diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 25502ff4f..708b12ef9 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2491,6 +2491,160 @@ func TestReleaseUpdateKeyPublicKeySSHAcceptsPublicKey(t *testing.T) { } } +func TestReleaseUpdateKeyVerifiesDetachedUpdateSignature(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate signing key: %v", err) + } + root := t.TempDir() + artifact := filepath.Join(root, "artifact") + signature := filepath.Join(root, "artifact.sig") + if err := os.WriteFile(artifact, []byte("release bytes"), 0o600); err != nil { + t.Fatalf("write artifact: %v", err) + } + + sign := exec.Command("go", "run", "./scripts/release_update_key.go", "sign", "--private-key", base64.StdEncoding.EncodeToString(privateKey), "--file", artifact) + sign.Dir = repoFile() + signatureBytes, err := sign.CombinedOutput() + if err != nil { + t.Fatalf("sign release artifact: %v\n%s", err, signatureBytes) + } + if err := os.WriteFile(signature, signatureBytes, 0o600); err != nil { + t.Fatalf("write detached signature: %v", err) + } + + verify := exec.Command("go", "run", "./scripts/release_update_key.go", "verify", "--public-key", base64.StdEncoding.EncodeToString(publicKey), "--file", artifact, "--signature-file", signature) + verify.Dir = repoFile() + if output, err := verify.CombinedOutput(); err != nil { + t.Fatalf("verify release artifact: %v\n%s", err, output) + } + if err := os.WriteFile(artifact, []byte("tampered bytes"), 0o600); err != nil { + t.Fatalf("tamper artifact: %v", err) + } + verify = exec.Command("go", "run", "./scripts/release_update_key.go", "verify", "--public-key", base64.StdEncoding.EncodeToString(publicKey), "--file", artifact, "--signature-file", signature) + verify.Dir = repoFile() + if output, err := verify.CombinedOutput(); err == nil || !strings.Contains(string(output), "signature verification failed") { + t.Fatalf("tampered release artifact passed verification: err=%v output=%s", err, output) + } +} + +func TestSecureRuntimeQualificationPacketIsHostedAndReleaseBound(t *testing.T) { + read := func(parts ...string) string { + t.Helper() + content, err := os.ReadFile(repoFile(parts...)) + if err != nil { + t.Fatalf("read %s: %v", strings.Join(parts, "/"), err) + } + return string(content) + } + + compilerWorkflow := read(".github", "workflows", "compile-release-payload.yml") + hostedJob := workflowJobBlock(t, compilerWorkflow, "compile-secure-runtime-qualification") + for _, required := range []string{ + "runs-on: ubuntu-24.04", + "attestations: write", + "id-token: write", + "./scripts/build-secure-runtime-qualification.sh", + "secure-runtime-compiler-subjects.sha256", + "secure-runtime-compiler-provenance.sigstore.json", + } { + if !strings.Contains(hostedJob, required) { + t.Fatalf("hosted secure-runtime compiler job missing %q", required) + } + } + if strings.Contains(hostedJob, "PULSE_UPDATE_SIGNING_KEY") || strings.Contains(hostedJob, "PULSE_LICENSE_PUBLIC_KEY") { + t.Fatal("hosted secure-runtime compiler must not receive private signing or license material") + } + + builder := read("scripts", "build-secure-runtime-qualification.sh") + for _, required := range []string{ + "go build -buildvcs=false -trimpath", + "collector_v1_version=\"${predecessor_base}-0.secure.v6.1\"", + "collector_v3_version=\"${predecessor_base}-0.secure.v6.3\"", + "compiler_runner_trust\": \"github-hosted-deny-self-hosted\"", + "secure-runtime-build-contract-v1.json", + } { + if !strings.Contains(builder, required) { + t.Fatalf("secure-runtime qualification builder missing %q", required) + } + } + + candidateWorkflow := read(".github", "workflows", "build-release-candidate.yml") + for _, required := range []string{ + "secure_runtime_artifact_digest", + "Verify hosted secure-runtime compiler packet", + "--deny-self-hosted-runners", + "secure-runtime-compiler-provenance.sigstore.json", + "cmp secure-runtime-qualification/pulse-agent-linux-amd64 release-compiled/payload/binaries/pulse-agent-linux-amd64", + "PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION: \"true\"", + } { + if !strings.Contains(candidateWorkflow, required) { + t.Fatalf("candidate workflow missing secure-runtime packet binding %q", required) + } + } + + buildRelease := read("scripts", "build-release.sh") + if strings.Index(buildRelease, "Imported hosted secure-runtime qualification packet") > strings.Index(buildRelease, "pulse_release_generate_packet_sbom") { + t.Fatal("secure-runtime packet must be imported before SBOM and checksum generation") + } + for _, required := range []string{ + "hosted secure-runtime collector-v4 does not reproduce the release collector", + "secure-runtime-build-contract-v1.json", + "secure-runtime-compiler-provenance.sigstore.json", + } { + if !strings.Contains(buildRelease, required) { + t.Fatalf("build-release.sh missing secure-runtime import guard %q", required) + } + } + + assetHelper := read("scripts", "release_asset_common.sh") + for _, required := range []string{ + `pulse-agent-runner-linux-*`, + `pulse-secure-runtime-collector-v*-linux-*`, + `secure-runtime-build-contract-v1.json`, + `secure-runtime-compiler-provenance.sigstore.json`, + } { + if !strings.Contains(assetHelper, required) { + t.Fatalf("release checksum inventory missing %q", required) + } + } + + publicationWorkflow := read(".github", "workflows", "create-release.yml") + for _, required := range []string{ + "release/secure-runtime-build-contract-v1.json", + "release/secure-runtime-compiler-provenance.sigstore.json", + "release/pulse-secure-runtime-collector-v1-linux-amd64", + "release/pulse-secure-runtime-collector-v3-linux-amd64", + "qualify-secure-runtime-release.yml/dispatches", + `{ref: "main", return_run_details: true, inputs: {tag: $tag}}`, + "Secure-runtime qualification dispatch did not return an exact workflow run.", + "Immutable RC publication did not retain an exact secure-runtime qualification run identity.", + } { + if !strings.Contains(publicationWorkflow, required) { + t.Fatalf("release publication missing secure-runtime asset %q", required) + } + } + + qualificationWorkflow := read(".github", "workflows", "qualify-secure-runtime-release.yml") + if strings.Contains(qualificationWorkflow, "release:\n types: [published]") { + t.Fatal("secure-runtime qualification must be explicitly dispatched after immutable publication, not rely on suppressed release events") + } + for _, required := range []string{ + ".immutable == true", + "ca-certificates curl dbus systemd systemd-sysv util-linux", + `for command in curl id nsenter runuser systemctl`, + "PULSE_SECURE_RUNTIME_SYSTEMD_LAB=disposable-v1", + "^TestSecureRuntimeSystemdLab$", + "--release-candidate-tag", + "--collector-v4-signature", + "secure_runtime_attestation_v6.py", + } { + if !strings.Contains(qualificationWorkflow, required) { + t.Fatalf("post-publication secure-runtime qualification missing %q", required) + } + } +} + func TestReleaseAssetCommonRunsUpdateKeyThroughModulePath(t *testing.T) { if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash not installed") diff --git a/scripts/release_asset_common.sh b/scripts/release_asset_common.sh index 713e46494..dff43526a 100644 --- a/scripts/release_asset_common.sh +++ b/scripts/release_asset_common.sh @@ -254,6 +254,18 @@ pulse_release_collect_checksum_files() { if compgen -G "pulse-agent-helper-linux-*" > /dev/null; then checksum_files+=( pulse-agent-helper-linux-* ) fi + if compgen -G "pulse-agent-runner-linux-*" > /dev/null; then + checksum_files+=( pulse-agent-runner-linux-* ) + fi + if compgen -G "pulse-secure-runtime-collector-v*-linux-*" > /dev/null; then + checksum_files+=( pulse-secure-runtime-collector-v*-linux-* ) + fi + if [[ -f "secure-runtime-build-contract-v1.json" ]]; then + checksum_files+=( secure-runtime-build-contract-v1.json ) + fi + if [[ -f "secure-runtime-compiler-provenance.sigstore.json" ]]; then + checksum_files+=( secure-runtime-compiler-provenance.sigstore.json ) + fi if compgen -G "pulse-agent-freebsd-*" > /dev/null; then checksum_files+=( pulse-agent-freebsd-* ) fi diff --git a/scripts/release_control/secure_runtime_attestation_v6.py b/scripts/release_control/secure_runtime_attestation_v6.py index 0c6a1891b..25566c6e0 100644 --- a/scripts/release_control/secure_runtime_attestation_v6.py +++ b/scripts/release_control/secure_runtime_attestation_v6.py @@ -40,6 +40,7 @@ BUILD_CONTRACT_NAME = "secure-runtime-build-contract-v1.json" CHECKSUMS_NAME = "checksums.txt" ASSEMBLY_PROVENANCE_NAME = "release-build-provenance.sigstore.json" COMPILER_PROVENANCE_NAME = "secure-runtime-compiler-provenance.sigstore.json" +COLLECTOR_SIGNATURE_NAMES = ("collector_v1", "collector_v2", "collector_v3", "collector_v4") REQUIRED_SCENARIOS = ( "legacy_root_command_capable_install", @@ -376,6 +377,37 @@ def immutable_artifact_snapshot( ) +@contextlib.contextmanager +def immutable_signature_snapshot( + signatures: dict[str, Path], expected_assets: dict[str, str] +) -> Iterator[tuple[dict[str, Path], dict[str, str]]]: + if set(signatures) != set(COLLECTOR_SIGNATURE_NAMES): + raise v5.AttestationError("collector release signature set is incomplete") + with tempfile.TemporaryDirectory(prefix="pulse-secure-runtime-signatures-") as temporary: + snapshot_root = Path(temporary) + snapshot_root.chmod(0o700) + snapshots: dict[str, Path] = {} + digests: dict[str, str] = {} + for name in COLLECTOR_SIGNATURE_NAMES: + expected_filename = expected_assets[name] + ".sig" + source = signatures[name] + if source.name != expected_filename: + raise v5.AttestationError( + f"collector release signature {name} must be named {expected_filename}" + ) + snapshot = snapshot_root / expected_filename + digests[name] = copy_immutable_input( + source, snapshot, f"collector release signature {name}" + ) + snapshots[name] = snapshot + yield snapshots, digests + for name, path in snapshots.items(): + if sha256_file(path) != digests[name]: + raise v5.AttestationError( + f"private collector release signature snapshot {name} changed during verification" + ) + + def verify_release_build_contract( *, path: Path, @@ -516,6 +548,7 @@ def verify_release_candidate_packet( receipt: dict[str, Any], artifacts: dict[str, Path], artifact_hashes: dict[str, str], + collector_signatures: dict[str, Path], ) -> dict[str, Any]: tag_identity = verify_release_candidate_tag_identity(checkout, qualified_commit, tag, repository) if not re.fullmatch(r"[1-9][0-9]*", release_id): @@ -595,6 +628,27 @@ def verify_release_candidate_packet( cwd=checkout, label=f"hosted compiler provenance verification for {artifact_name}", ) + run_checked( + [ + "gh", + "attestation", + "verify", + str(build_contract_snapshot), + "--repo", + repository, + "--signer-workflow", + COMPILER_SIGNER_WORKFLOW, + "--source-digest", + qualified_commit, + "--deny-self-hosted-runners", + "--predicate-type", + "https://slsa.dev/provenance/v1", + "--bundle", + str(compiler_provenance_snapshot), + ], + cwd=checkout, + label="hosted compiler provenance verification for the build contract", + ) verify_release_sidecar_snapshot_unchanged(snapshots, snapshot_digests) checksums = parse_checksums(checksums_snapshot) build_identity = verify_release_build_contract( @@ -607,6 +661,50 @@ def verify_release_candidate_packet( artifact_hashes=artifact_hashes, checksums=checksums, ) + release_assets = { + name: str(build_identity[name]["release_asset"]) + for name in COLLECTOR_SIGNATURE_NAMES + } + with immutable_signature_snapshot(collector_signatures, release_assets) as ( + signature_snapshots, + signature_digests, + ): + update_public_key = load_json_object( + build_contract_snapshot, "secure-runtime build contract" + )["update_public_keys"] + for name in COLLECTOR_SIGNATURE_NAMES: + signature_path = signature_snapshots[name] + run_checked( + [ + "gh", + "release", + "verify-asset", + tag, + str(signature_path), + "--repo", + repository, + "--format", + "json", + ], + cwd=checkout, + label=f"release attestation verification for {signature_path.name}", + ) + run_checked( + [ + "go", + "run", + "./scripts/release_update_key.go", + "verify", + "--public-key", + str(update_public_key), + "--file", + str(artifacts[name]), + "--signature-file", + str(signature_path), + ], + cwd=checkout, + label=f"production update signature verification for {name}", + ) verify_release_sidecar_snapshot_unchanged(snapshots, snapshot_digests) return { **tag_identity, @@ -619,6 +717,14 @@ def verify_release_candidate_packet( "compiler_signer_workflow": COMPILER_SIGNER_WORKFLOW, "compiler_runner_trust": "github-hosted-deny-self-hosted", "build_identity": build_identity, + "collector_signatures": { + name: { + "release_asset": release_assets[name] + ".sig", + "sha256": signature_digests[name], + "verified_with_release_update_key": True, + } + for name in COLLECTOR_SIGNATURE_NAMES + }, "update_key_fingerprint": expected_update_key_fingerprint, } @@ -641,6 +747,7 @@ def _create_attestation_with_snapshotted_artifacts( release_compiler_provenance_path: Path | None = None, release_build_contract_path: Path | None = None, expected_release_update_key_fingerprint: str | None = None, + collector_signatures: dict[str, Path] | None = None, ) -> dict[str, Any]: checkout = checkout.resolve() qualified_commit = v5.resolve_commit(checkout, commit, "qualified commit") @@ -673,6 +780,7 @@ def _create_attestation_with_snapshotted_artifacts( release_compiler_provenance_path, release_build_contract_path, expected_release_update_key_fingerprint, + collector_signatures, ) release_packet: dict[str, Any] | None = None if release_candidate_tag is None: @@ -683,7 +791,7 @@ def _create_attestation_with_snapshotted_artifacts( else: if any(value is None for value in release_arguments): raise v5.AttestationError( - "release-candidate classification requires repository, release id, signed checksums, hosted assembly and compiler provenance, build contract, and update-key fingerprint" + "release-candidate classification requires repository, release id, signed checksums, hosted assembly and compiler provenance, build contract, four collector signatures, and update-key fingerprint" ) release_packet = verify_release_candidate_packet( checkout=checkout, @@ -699,6 +807,7 @@ def _create_attestation_with_snapshotted_artifacts( receipt=receipt, artifacts=artifacts, artifact_hashes=artifact_hashes, + collector_signatures=dict(collector_signatures), ) artifact_build_identity = release_packet["build_identity"] classification = "release-candidate-hosted-compiler-chain-artifact-bound-self-attested-systemd" @@ -777,6 +886,7 @@ def create_attestation( release_compiler_provenance_path: Path | None = None, release_build_contract_path: Path | None = None, expected_release_update_key_fingerprint: str | None = None, + collector_signatures: dict[str, Path] | None = None, ) -> dict[str, Any]: # Hash, inspect, and externally verify the same private artifact bytes. # Caller-owned paths can otherwise be swapped between receipt hashing, @@ -799,6 +909,7 @@ def create_attestation( release_compiler_provenance_path=release_compiler_provenance_path, release_build_contract_path=release_build_contract_path, expected_release_update_key_fingerprint=expected_release_update_key_fingerprint, + collector_signatures=collector_signatures, ) @@ -825,6 +936,10 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace: parser.add_argument("--release-compiler-provenance", type=Path) parser.add_argument("--release-build-contract", type=Path) parser.add_argument("--expected-release-update-key-fingerprint") + parser.add_argument("--collector-v1-signature", type=Path) + parser.add_argument("--collector-v2-signature", type=Path) + parser.add_argument("--collector-v3-signature", type=Path) + parser.add_argument("--collector-v4-signature", type=Path) parser.add_argument("--output", type=Path, required=True) return parser.parse_args(argv) @@ -839,6 +954,21 @@ def main(argv: Sequence[str] | None = None) -> int: "helper": args.helper, "runner": args.runner, } + collector_signatures = None + signature_arguments = ( + args.collector_v1_signature, + args.collector_v2_signature, + args.collector_v3_signature, + args.collector_v4_signature, + ) + if any(value is not None for value in signature_arguments): + if any(value is None for value in signature_arguments): + print( + "secure runtime schema-v6 attestation failed: all four collector signatures are required together", + file=sys.stderr, + ) + return 1 + collector_signatures = dict(zip(COLLECTOR_SIGNATURE_NAMES, signature_arguments, strict=True)) try: attestation = create_attestation( checkout=args.checkout, @@ -857,6 +987,7 @@ def main(argv: Sequence[str] | None = None) -> int: release_compiler_provenance_path=args.release_compiler_provenance, release_build_contract_path=args.release_build_contract, expected_release_update_key_fingerprint=args.expected_release_update_key_fingerprint, + collector_signatures=collector_signatures, ) args.output.write_text(json.dumps(attestation, indent=2) + "\n", encoding="utf-8") except v5.AttestationError as exc: diff --git a/scripts/release_control/secure_runtime_attestation_v6_test.py b/scripts/release_control/secure_runtime_attestation_v6_test.py index 928b1b960..a8c3548fb 100644 --- a/scripts/release_control/secure_runtime_attestation_v6_test.py +++ b/scripts/release_control/secure_runtime_attestation_v6_test.py @@ -75,6 +75,12 @@ class SecureRuntimeAttestationV6Test(unittest.TestCase): path = self.root / f"artifact-{name}" path.write_bytes(name.encode()) self.artifacts[name] = path + contract = self.build_contract() + self.collector_signatures = {} + for name in ("collector_v1", "collector_v2", "collector_v3", "collector_v4"): + path = self.root / f"{contract['artifacts'][name]['release_asset']}.sig" + path.write_text(base64.b64encode(name.encode()).decode() + "\n", encoding="utf-8") + self.collector_signatures[name] = path def tearDown(self) -> None: self.temporary.cleanup() @@ -453,17 +459,60 @@ class SecureRuntimeAttestationV6Test(unittest.TestCase): receipt=self.receipt, artifacts=self.artifacts, artifact_hashes=self.artifact_hashes, + collector_signatures=self.collector_signatures, ) self.assertEqual(packet["assembly_signer_workflow"], ASSEMBLY_SIGNER_WORKFLOW) self.assertEqual(packet["compiler_signer_workflow"], COMPILER_SIGNER_WORKFLOW) self.assertEqual(packet["compiler_runner_trust"], "github-hosted-deny-self-hosted") self.assertTrue(any(Path(call[0]).name == "verify-github-release-integrity.sh" for call in calls)) - self.assertEqual(sum(call[:3] == ["gh", "release", "verify-asset"] for call in calls), 4) + self.assertEqual(sum(call[:3] == ["gh", "release", "verify-asset"] for call in calls), 8) provenance_calls = [call for call in calls if call[:3] == ["gh", "attestation", "verify"]] - self.assertEqual(len(provenance_calls), 1 + len(v5.ARTIFACT_ARGUMENTS)) + self.assertEqual(len(provenance_calls), 2 + len(v5.ARTIFACT_ARGUMENTS)) self.assertTrue(all("--deny-self-hosted-runners" in call for call in provenance_calls)) self.assertIn(ASSEMBLY_SIGNER_WORKFLOW, provenance_calls[0]) self.assertTrue(all(COMPILER_SIGNER_WORKFLOW in call for call in provenance_calls[1:])) + signature_calls = [call for call in calls if call[:4] == ["go", "run", "./scripts/release_update_key.go", "verify"]] + self.assertEqual(len(signature_calls), 4) + self.assertTrue(all(self.update_public_keys in call for call in signature_calls)) + self.assertEqual(set(packet["collector_signatures"]), {"collector_v1", "collector_v2", "collector_v3", "collector_v4"}) + + def test_release_packet_rejects_misnamed_collector_signature(self) -> None: + contract_path, checksums_path, _ = self.write_contract_and_checksums() + assembly_provenance_path = self.root / ASSEMBLY_PROVENANCE_NAME + assembly_provenance_path.write_text("{}\n", encoding="utf-8") + compiler_provenance_path = self.root / COMPILER_PROVENANCE_NAME + compiler_provenance_path.write_text("{}\n", encoding="utf-8") + signatures = dict(self.collector_signatures) + signatures["collector_v1"] = self.root / "wrong.sig" + signatures["collector_v1"].write_text("bad\n", encoding="utf-8") + + with ( + mock.patch( + "secure_runtime_attestation_v6.verify_release_candidate_tag_identity", + return_value={"tag": self.tag}, + ), + mock.patch( + "secure_runtime_attestation_v6.subprocess.run", + return_value=subprocess.CompletedProcess([], 0, b"{}\n", b""), + ), + self.assertRaisesRegex(v5.AttestationError, "must be named"), + ): + verify_release_candidate_packet( + checkout=self.root, + qualified_commit=self.commit, + tag=self.tag, + repository=CANONICAL_REPOSITORY, + release_id="12345", + checksums_path=checksums_path, + assembly_provenance_path=assembly_provenance_path, + compiler_provenance_path=compiler_provenance_path, + build_contract_path=contract_path, + expected_update_key_fingerprint=self.fingerprint, + receipt=self.receipt, + artifacts=self.artifacts, + artifact_hashes=self.artifact_hashes, + collector_signatures=signatures, + ) def test_release_packet_rejects_private_snapshot_swap_during_verification(self) -> None: contract_path, checksums_path, _ = self.write_contract_and_checksums() @@ -501,6 +550,7 @@ class SecureRuntimeAttestationV6Test(unittest.TestCase): receipt=self.receipt, artifacts=self.artifacts, artifact_hashes=self.artifact_hashes, + collector_signatures=self.collector_signatures, ) def test_cli_does_not_accept_v5_release_candidate_ref_shortcut(self) -> None: diff --git a/scripts/release_control/secure_runtime_source_manifest_v6.json b/scripts/release_control/secure_runtime_source_manifest_v6.json index c8aebd4fe..11523c46d 100644 --- a/scripts/release_control/secure_runtime_source_manifest_v6.json +++ b/scripts/release_control/secure_runtime_source_manifest_v6.json @@ -7,6 +7,7 @@ ".github/workflows/build-release-candidate.yml", ".github/workflows/compile-release-payload.yml", ".github/workflows/create-release.yml", + ".github/workflows/qualify-secure-runtime-release.yml", "VERSION", "go.mod", "go.sum", @@ -29,6 +30,7 @@ "scripts/backfill-release-assets.sh", "scripts/build-release-binaries.sh", "scripts/build-release.sh", + "scripts/build-secure-runtime-qualification.sh", "scripts/check-github-release-immutability.sh", "scripts/package-helm-chart.sh", "scripts/prepare-release-container-context.sh", diff --git a/scripts/release_update_key.go b/scripts/release_update_key.go index ef6f9faff..9e01907ce 100644 --- a/scripts/release_update_key.go +++ b/scripts/release_update_key.go @@ -3,6 +3,7 @@ package main import ( + "bytes" "crypto/ed25519" "crypto/sha256" "crypto/x509" @@ -21,6 +22,7 @@ func usage() { fmt.Fprintln(os.Stderr, " release_update_key.go openssh-private-key --private-key [--comment ]") fmt.Fprintln(os.Stderr, " release_update_key.go fingerprint (--private-key | --public-key )") fmt.Fprintln(os.Stderr, " release_update_key.go sign --private-key --file ") + fmt.Fprintln(os.Stderr, " release_update_key.go verify --public-key --file --signature-file ") os.Exit(1) } @@ -147,6 +149,36 @@ func main() { fail(err) } fmt.Println(signature) + case "verify": + verifyCmd := flag.NewFlagSet("verify", flag.ExitOnError) + publicKey := verifyCmd.String("public-key", "", "base64-encoded Ed25519 public key or PKIX public key") + filePath := verifyCmd.String("file", "", "path to the signed file") + signaturePath := verifyCmd.String("signature-file", "", "path to the base64-encoded Ed25519 signature") + _ = verifyCmd.Parse(os.Args[2:]) + + if *publicKey == "" || *filePath == "" || *signaturePath == "" { + usage() + } + key, err := decodePublicKey(*publicKey) + if err != nil { + fail(err) + } + data, err := os.ReadFile(*filePath) + if err != nil { + fail(fmt.Errorf("read file for verification: %w", err)) + } + signatureText, err := os.ReadFile(*signaturePath) + if err != nil { + fail(fmt.Errorf("read signature for verification: %w", err)) + } + signature, err := base64.StdEncoding.DecodeString(string(bytes.TrimSpace(signatureText))) + if err != nil { + fail(fmt.Errorf("invalid base64 signature: %w", err)) + } + if !ed25519.Verify(key, data, signature) { + fail(fmt.Errorf("signature verification failed")) + } + fmt.Println("signature verified") default: usage() } diff --git a/scripts/validate-release.sh b/scripts/validate-release.sh index 2a58f0451..73a89254e 100755 --- a/scripts/validate-release.sh +++ b/scripts/validate-release.sh @@ -466,6 +466,15 @@ required_assets=( "pulse-agent-v${PULSE_VERSION}-freebsd-amd64.tar.gz" "pulse-agent-v${PULSE_VERSION}-freebsd-arm64.tar.gz" ) +if [[ "${PULSE_REQUIRE_SECURE_RUNTIME_QUALIFICATION:-false}" == "true" ]]; then + required_assets+=( + "pulse-secure-runtime-collector-v1-linux-amd64" + "pulse-secure-runtime-collector-v2-linux-amd64" + "pulse-secure-runtime-collector-v3-linux-amd64" + "secure-runtime-build-contract-v1.json" + "secure-runtime-compiler-provenance.sigstore.json" + ) +fi missing_count=0 for asset in "${required_assets[@]}"; do