diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 9a2819e68..ec5a4060e 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -288,6 +288,20 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Derive license public key Docker cache key + id: license_key_cache + env: + PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} + run: | + set -euo pipefail + decoded_len="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | wc -c | tr -d ' ')" + if [ "${decoded_len}" != "32" ]; then + echo "PULSE_LICENSE_PUBLIC_KEY must decode to 32 bytes." >&2 + exit 1 + fi + key_sha256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')" + echo "sha256=${key_sha256}" >> "${GITHUB_OUTPUT}" + - name: Build Docker image (verify only) uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: @@ -302,6 +316,7 @@ jobs: cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache,mode=max build-args: | VERSION=${{ needs.prepare.outputs.tag }} + PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} @@ -321,6 +336,7 @@ jobs: cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache,mode=max build-args: | VERSION=${{ needs.prepare.outputs.tag }} + PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} @@ -347,11 +363,13 @@ jobs: PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} run: | + PULSE_LICENSE_PUBLIC_KEY_SHA256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')" docker build \ --target runtime \ --secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY \ --secret id=pulse_update_signing_key,env=PULSE_UPDATE_SIGNING_KEY \ --build-arg VERSION="${{ needs.prepare.outputs.tag }}" \ + --build-arg PULSE_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256}" \ --build-arg PULSE_UPDATE_SIGNING_PUBLIC_KEY="${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ -t pulse-helm-smoke:${{ needs.prepare.outputs.version }} \ . diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index b75275a97..d761e92ea 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -127,6 +127,20 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Derive license public key Docker cache key + id: license_key_cache + env: + PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} + run: | + set -euo pipefail + decoded_len="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | wc -c | tr -d ' ')" + if [ "${decoded_len}" != "32" ]; then + echo "PULSE_LICENSE_PUBLIC_KEY must decode to 32 bytes." >&2 + exit 1 + fi + key_sha256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')" + echo "sha256=${key_sha256}" >> "${GITHUB_OUTPUT}" + - name: Build and push Pulse server image (multi-arch) id: build_server_image uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 @@ -140,6 +154,7 @@ jobs: cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache build-args: | VERSION=${{ steps.version.outputs.tag }} + PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} @@ -182,6 +197,7 @@ jobs: cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache build-args: | VERSION=${{ steps.version.outputs.tag }} + PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} diff --git a/Dockerfile b/Dockerfile index b09168c2f..99affc323 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,7 @@ FROM --platform=linux/amd64 golang:1.25.9-alpine@sha256:5caaf1cca9dc351e13deafbc ARG BUILD_AGENT ARG VERSION +ARG PULSE_LICENSE_PUBLIC_KEY_SHA256 ARG PULSE_UPDATE_SIGNING_PUBLIC_KEY WORKDIR /app @@ -64,9 +65,19 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \ GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \ LICENSE_PUBLIC_KEY="" && \ + EXPECTED_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256#SHA256:}" && \ UPDATE_SIGNING_KEY="" && \ UPDATE_PUBLIC_KEYS="" && \ if [ -f /run/secrets/pulse_license_public_key ]; then LICENSE_PUBLIC_KEY="$(tr -d '\r\n' < /run/secrets/pulse_license_public_key)"; fi && \ + if [ -n "${LICENSE_PUBLIC_KEY}" ]; then \ + LICENSE_PUBLIC_KEY_BYTES="$(printf '%s' "${LICENSE_PUBLIC_KEY}" | base64 -d | wc -c | tr -d ' ')" && \ + if [ "${LICENSE_PUBLIC_KEY_BYTES}" != "32" ]; then echo "Error: mounted license public key must decode to 32 bytes." >&2; exit 1; fi; \ + fi && \ + if [ -n "${EXPECTED_LICENSE_PUBLIC_KEY_SHA256}" ]; then \ + if [ -z "${LICENSE_PUBLIC_KEY}" ]; then echo "Error: PULSE_LICENSE_PUBLIC_KEY_SHA256 was provided but no license public key was mounted." >&2; exit 1; fi && \ + ACTUAL_LICENSE_PUBLIC_KEY_SHA256="$(printf '%s' "${LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')" && \ + if [ "${ACTUAL_LICENSE_PUBLIC_KEY_SHA256}" != "${EXPECTED_LICENSE_PUBLIC_KEY_SHA256}" ]; then echo "Error: mounted license public key does not match PULSE_LICENSE_PUBLIC_KEY_SHA256." >&2; exit 1; fi; \ + fi && \ if [ -f /run/secrets/pulse_update_signing_key ]; then UPDATE_SIGNING_KEY="$(tr -d '\r\n' < /run/secrets/pulse_update_signing_key)"; fi && \ if [ -n "${UPDATE_SIGNING_KEY}" ]; then UPDATE_PUBLIC_KEYS="$(go run ./scripts/release_update_key.go public-key --private-key "${UPDATE_SIGNING_KEY}")"; fi && \ if [ -n "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" ] && [ -z "${UPDATE_PUBLIC_KEYS}" ]; then echo "Error: PULSE_UPDATE_SIGNING_PUBLIC_KEY was provided but no update signing key was mounted." >&2; exit 1; fi && \ diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 48cf8fb14..8c311a5e3 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -341,7 +341,10 @@ the mounted license public key through `PULSE_LICENSE_PUBLIC_KEY_SHA256` and the `Dockerfile` must verify that fingerprint before embedding the key. A release image build must fail closed if the fingerprint is present but the secret is missing, malformed, or mismatched, so cached no-key binaries cannot -be reused for release-grade hosted or self-hosted runtime images. +be reused for release-grade hosted or self-hosted runtime images. The matching +installability proof lives in `scripts/installtests/build_release_assets_test.go` +and `scripts/release_control/release_promotion_policy_test.py`, and both must +assert the secret mount and non-secret fingerprint argument together. That same supply-chain boundary also owns the checked-in build roots themselves. `Dockerfile` must pin its Node, Go, and Alpine bases by immutable manifest-list digest so multi-arch release builds do not silently drift onto a diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index b598b752a..6d4dda762 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -303,10 +303,13 @@ func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) { `COPY scripts/release_ldflags.sh ./scripts/release_ldflags.sh`, `COPY scripts/release_update_key.go ./scripts/release_update_key.go`, `COPY scripts/render_installers.go ./scripts/render_installers.go`, + `ARG PULSE_LICENSE_PUBLIC_KEY_SHA256`, `--mount=type=secret,id=pulse_license_public_key,required=false`, `--mount=type=secret,id=pulse_update_signing_key,required=false`, `ARG PULSE_UPDATE_SIGNING_PUBLIC_KEY`, `LICENSE_PUBLIC_KEY="$(tr -d '\r\n' < /run/secrets/pulse_license_public_key)"`, + `EXPECTED_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256#SHA256:}"`, + `mounted license public key does not match PULSE_LICENSE_PUBLIC_KEY_SHA256.`, `UPDATE_PUBLIC_KEYS="$(go run ./scripts/release_update_key.go public-key --private-key "${UPDATE_SIGNING_KEY}")"`, `mounted update signing key does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY.`, `./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}"`, @@ -367,6 +370,8 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { `provenance: mode=max`, `sbom: true`, `secrets: |`, + `id: license_key_cache`, + `PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}`, `PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, `pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}`, `pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}`, @@ -374,6 +379,7 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { `DOCKER_BUILDKIT: 1`, `--secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY`, `--secret id=pulse_update_signing_key,env=PULSE_UPDATE_SIGNING_KEY`, + `--build-arg PULSE_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256}"`, `--build-arg PULSE_UPDATE_SIGNING_PUBLIC_KEY="${PULSE_UPDATE_SIGNING_PUBLIC_KEY}"`, `id-token: write`, `attestations: write`, @@ -397,6 +403,8 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { `provenance: mode=max`, `sbom: true`, `secrets: |`, + `id: license_key_cache`, + `PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}`, `PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, `pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}`, `pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}`, diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index c85109a27..0a22dae1f 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -346,10 +346,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}"', backfill_workflow) self.assertIn('./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}"', backfill_workflow) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", backfill_workflow) + self.assertIn("id: license_key_cache", content) + self.assertIn("PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}", content) self.assertIn("pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}", content) self.assertIn("pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content) self.assertIn("--secret id=pulse_license_public_key,env=PULSE_LICENSE_PUBLIC_KEY", content) self.assertIn("--secret id=pulse_update_signing_key,env=PULSE_UPDATE_SIGNING_KEY", content) + self.assertIn('--build-arg PULSE_LICENSE_PUBLIC_KEY_SHA256="${PULSE_LICENSE_PUBLIC_KEY_SHA256}"', content) self.assertNotIn("provenance: false", content) self.assertIn("Derived rollback command:", helper) self.assertIn("./scripts/install.sh --version", helper) @@ -424,6 +427,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("subject-name: docker.io/rcourtman/pulse-agent", publish) self.assertIn("subject-name: ghcr.io/${{ github.repository_owner }}/pulse-agent", publish) self.assertIn("create-storage-record: false", publish) + self.assertIn("id: license_key_cache", publish) + self.assertIn("PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }}", publish) self.assertIn("pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }}", publish) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", publish) self.assertNotIn("provenance: false", publish)