Wire secure runtime RC qualification

This commit is contained in:
rcourtman
2026-08-31 01:50:48 +01:00
parent 92b5f3a9e1
commit 7650fdaba3
18 changed files with 1184 additions and 18 deletions
@@ -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
@@ -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
+55 -1
View File
@@ -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
@@ -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
+8 -3
View File
@@ -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
@@ -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,
@@ -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.
@@ -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"
]
},
{
@@ -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
+59
View File
@@ -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[@]}"
+216
View File
@@ -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 <release-version> <output-directory> [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}."
@@ -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")
+12
View File
@@ -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
@@ -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:
@@ -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:
@@ -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",
+32
View File
@@ -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 <base64-ed25519-key-or-seed> [--comment <comment>]")
fmt.Fprintln(os.Stderr, " release_update_key.go fingerprint (--private-key <base64-ed25519-key-or-seed> | --public-key <base64-ed25519-public-key>)")
fmt.Fprintln(os.Stderr, " release_update_key.go sign --private-key <base64-ed25519-key-or-seed> --file <path>")
fmt.Fprintln(os.Stderr, " release_update_key.go verify --public-key <base64-ed25519-public-key> --file <path> --signature-file <path>")
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()
}
+9
View File
@@ -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