diff --git a/.github/workflows/backfill-release-assets.yml b/.github/workflows/backfill-release-assets.yml index 54ff257f6..5af93250c 100644 --- a/.github/workflows/backfill-release-assets.yml +++ b/.github/workflows/backfill-release-assets.yml @@ -52,6 +52,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} run: | ./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}" diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index a0b8fba6a..9a2819e68 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -302,6 +302,7 @@ jobs: cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache,mode=max build-args: | VERSION=${{ needs.prepare.outputs.tag }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }} @@ -320,6 +321,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_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }} @@ -343,12 +345,14 @@ jobs: DOCKER_BUILDKIT: 1 PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} run: | 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_UPDATE_SIGNING_PUBLIC_KEY="${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ -t pulse-helm-smoke:${{ needs.prepare.outputs.version }} \ . @@ -605,6 +609,7 @@ jobs: env: PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} - name: Post-build health check run: | @@ -899,6 +904,7 @@ jobs: env: GH_TOKEN: ${{ github.token }} PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} run: | ./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}" diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 013e19b73..b75275a97 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -140,6 +140,7 @@ jobs: cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache build-args: | VERSION=${{ steps.version.outputs.tag }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }} @@ -181,6 +182,7 @@ jobs: cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse-agent:buildcache build-args: | VERSION=${{ steps.version.outputs.tag }} + PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }} diff --git a/Dockerfile b/Dockerfile index 6c6ffa788..d6acc4018 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_UPDATE_SIGNING_PUBLIC_KEY WORKDIR /app # Install build dependencies @@ -68,6 +69,8 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ if [ -f /run/secrets/pulse_license_public_key ]; then LICENSE_PUBLIC_KEY="$(tr -d '\r\n' < /run/secrets/pulse_license_public_key)"; 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 && \ + if [ -n "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" ] && [ "${UPDATE_PUBLIC_KEYS}" != "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" ]; then echo "Error: mounted update signing key does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY." >&2; echo "Expected public key: ${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" >&2; echo "Actual public key: ${UPDATE_PUBLIC_KEYS}" >&2; exit 1; fi && \ SERVER_LDFLAGS="$(./scripts/release_ldflags.sh server --version "${VERSION}" --build-time "${BUILD_TIME}" --git-commit "${GIT_COMMIT}" $(if [ -n "${LICENSE_PUBLIC_KEY}" ]; then printf '%s %s' --license-public-key "${LICENSE_PUBLIC_KEY}"; fi) $(if [ -n "${UPDATE_PUBLIC_KEYS}" ]; then printf '%s %s' --update-public-keys "${UPDATE_PUBLIC_KEYS}"; fi))" && \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -tags release \ @@ -91,6 +94,8 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ UPDATE_PUBLIC_KEYS="" && \ 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 && \ + if [ -n "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" ] && [ "${UPDATE_PUBLIC_KEYS}" != "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" ]; then echo "Error: mounted update signing key does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY." >&2; echo "Expected public key: ${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" >&2; echo "Actual public key: ${UPDATE_PUBLIC_KEYS}" >&2; exit 1; fi && \ AGENT_LDFLAGS="$(./scripts/release_ldflags.sh agent --version "${VERSION}" $(if [ -n "${UPDATE_PUBLIC_KEYS}" ]; then printf '%s %s' --update-public-keys "${UPDATE_PUBLIC_KEYS}"; fi))" && \ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ -ldflags="${AGENT_LDFLAGS}" \ @@ -147,7 +152,11 @@ RUN --mount=type=cache,id=pulse-go-mod,target=/go/pkg/mod \ mkdir -p /app/rendered-installers && \ UPDATE_SIGNING_KEY="" && \ INSTALLER_SSH_PUBLIC_KEY="" && \ + UPDATE_PUBLIC_KEYS="" && \ 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 && \ + if [ -n "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" ] && [ "${UPDATE_PUBLIC_KEYS}" != "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" ]; then echo "Error: mounted update signing key does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY." >&2; echo "Expected public key: ${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" >&2; echo "Actual public key: ${UPDATE_PUBLIC_KEYS}" >&2; exit 1; fi && \ if [ -n "${UPDATE_SIGNING_KEY}" ]; then INSTALLER_SSH_PUBLIC_KEY="$(go run ./scripts/release_update_key.go public-key-ssh --private-key "${UPDATE_SIGNING_KEY}" --comment pulse-installer)"; fi && \ go run ./scripts/render_installers.go --source-dir ./scripts --output-dir /app/rendered-installers --installer-ssh-public-key "${INSTALLER_SSH_PUBLIC_KEY}" && \ if [ -n "${UPDATE_SIGNING_KEY}" ]; then \ diff --git a/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md b/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md index 29d103794..be9f14f5e 100644 --- a/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md +++ b/docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md @@ -37,6 +37,7 @@ Use this as the final gate before cutting a Pulse v6 pre-release. ## Promotion Policy - [x] Record the previous stable tag and exact rollback pin command before publishing a new prerelease or stable release. +- [ ] For any prerelease or stable publication, confirm the repo variable `PULSE_UPDATE_SIGNING_PUBLIC_KEY` is set to the intended active update signer public key and that the release workflows are consuming it alongside `PULSE_UPDATE_SIGNING_KEY`, so accidental trust-root rotation fails closed before publication. - [ ] For the GA/stable candidate, confirm the release pipeline has already been exercised on a real prerelease tag, not only linted or YAML-parsed. - [x] For stable promotion, confirm the candidate commit has already shipped on `rc`. - [x] For stable promotion, confirm the chosen `promoted_from_tag` is a prerelease that was actually published through the governed prerelease path, not an accidental git tag. diff --git a/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md index 2c18e0018..5dd10a15f 100644 --- a/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md +++ b/docs/release-control/v6/internal/records/rc-to-ga-promotion-readiness-blocked-2026-04-04.md @@ -8,21 +8,24 @@ 1. The latest shipped Pulse v6 prerelease tag is `v6.0.0-rc.2`. 2. That shipped prerelease tag resolves to commit `7b0bdebdff8127228708e84eb836cfd3d7214c08`. -3. The governed release profile in `docs/release-control/control_plane.json` +3. The selected remote ref `origin/pulse/v6-release` is still behind the current + local governed branch state, so `Release Dry Run` would exercise stale remote + control-plane metadata instead of the intended candidate. +4. The governed release profile in `docs/release-control/control_plane.json` currently declares both `prerelease_branch` and `stable_branch` as `pulse/v6-release`. -4. The active control-plane target is still `v6-product-lane-expansion`, not +5. The active control-plane target is still `v6-product-lane-expansion`, not `v6-ga-promotion`. -5. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a +6. The active local `pulse/v6-release` branch currently reports `VERSION=6.0.0`, so a local GA candidate exists on the governed stable line. -6. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful +7. There is still no governed `Prerelease-to-GA Rehearsal Record` proving a successful non-publish `Release Dry Run` for the current `6.0.0` candidate. -7. `docs/releases/RELEASE_NOTES_v6.md` and +8. `docs/releases/RELEASE_NOTES_v6.md` and `docs/release-control/v6/internal/V5_MAINTENANCE_SUPPORT_POLICY.md` now carry the currently proposed exact dates for the eventual GA notice: - `v6` GA date: `2026-04-20` - `v5` end-of-support date: `2026-07-19` -8. There is still no governed `Release Dry Run` artifact or rehearsal record +9. There is still no governed `Release Dry Run` artifact or rehearsal record exercising stable inputs for: - `version=6.0.0` - `promoted_from_tag=v6.0.0-rc.2` diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 0a34a35c1..e216aae12 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -221,6 +221,11 @@ server-side update execution surfaces. Whenever that policy changes, update the owning workflow/install proof files in `scripts/installtests/build_release_assets_test.go` and `scripts/release_control/release_promotion_policy_*` in the same slice. +10. Keep forward release signing pinned to an explicit trust root. Governed + release scripts, Docker release builds, and historical backfill paths must + accept the active private signing key only alongside a non-secret expected + public key or equivalent pinned identity, and they must fail closed before + publication if the signer drifts from that expected trust root. ## Current State diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 222c31652..936b32499 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -1,7 +1,12 @@ package installtests import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -66,9 +71,13 @@ func TestBuildReleaseUsesV6InstallScripts(t *testing.T) { `: "${PULSE_REPO_ROOT:=$(cd "${PULSE_SCRIPTS_DIR}/.." && pwd)}"`, `go -C "${PULSE_REPO_ROOT}" run "${PULSE_SCRIPTS_DIR}/release_update_key.go" "$@"`, `pulse_release_go_run_update_key public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"`, + `pulse_release_go_run_update_key fingerprint --public-key "${PULSE_RELEASE_UPDATE_PUBLIC_KEY}"`, `pulse_release_go_run_update_key public-key-ssh --private-key "${PULSE_UPDATE_SIGNING_KEY}"`, `pulse_release_go_run_update_key openssh-private-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"`, `pulse_release_go_run_update_key sign --private-key "${PULSE_UPDATE_SIGNING_KEY}" --file "${absolute_file}"`, + `PULSE_UPDATE_SIGNING_PUBLIC_KEY`, + `PULSE_UPDATE_SIGNING_PUBLIC_KEY_FINGERPRINT`, + `Verified update signing public key fingerprint: ${PULSE_RELEASE_UPDATE_PUBLIC_KEY_FINGERPRINT}`, `ssh-keygen -q -Y sign`, `"${resolved_tool}" "dir:${release_dir}" -o "spdx-json=${tmp_sbom}"`, `if compgen -G "pulse-*.sbom.spdx.json" > /dev/null; then`, @@ -184,6 +193,7 @@ func TestBackfillReleaseWorkflowRepairsPublishedAssetsWithoutRebuilds(t *testing `SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"`, `./scripts/backfill-release-assets.sh --tag "${{ inputs.tag }}" --repo "${{ github.repository }}"`, `PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}`, + `PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, `./scripts/validate-published-release.sh "${{ inputs.tag }}" "${{ github.repository }}"`, } for _, needle := range workflowRequired { @@ -284,8 +294,10 @@ func TestDockerAndDemoBuildsUseCanonicalReleaseLdflags(t *testing.T) { `COPY scripts/render_installers.go ./scripts/render_installers.go`, `--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)"`, `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}"`, `./scripts/release_ldflags.sh agent --version "${VERSION}"`, `go run ./scripts/render_installers.go --source-dir ./scripts --output-dir /app/rendered-installers`, @@ -334,11 +346,14 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { `provenance: mode=max`, `sbom: true`, `secrets: |`, + `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 }}`, + `PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}`, `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_UPDATE_SIGNING_PUBLIC_KEY="${PULSE_UPDATE_SIGNING_PUBLIC_KEY}"`, `id-token: write`, `attestations: write`, `uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4`, @@ -361,6 +376,7 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { `provenance: mode=max`, `sbom: true`, `secrets: |`, + `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 }}`, `subject-name: docker.io/rcourtman/pulse`, @@ -549,6 +565,58 @@ func TestDockerfileStagesShippedDocsForEmbeddedFrontendBuild(t *testing.T) { } } +func TestReleaseUpdateKeyFingerprintUsesCanonicalRawPublicKeyHash(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate signing key: %v", err) + } + + cmd := exec.Command("go", "run", "./scripts/release_update_key.go", "fingerprint", "--private-key", base64.StdEncoding.EncodeToString(privateKey)) + cmd.Dir = repoFile() + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("release_update_key.go fingerprint failed: %v\n%s", err, output) + } + + sum := sha256.Sum256(publicKey) + expected := "SHA256:" + base64.StdEncoding.EncodeToString(sum[:]) + if got := strings.TrimSpace(string(output)); got != expected { + t.Fatalf("fingerprint mismatch: got %q want %q", got, expected) + } +} + +func TestReleaseAssetCommonRejectsUnexpectedUpdateSigningPublicKey(t *testing.T) { + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not installed") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go not installed") + } + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate signing key: %v", err) + } + unexpectedPublicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate unexpected public key: %v", err) + } + + cmd := exec.Command("bash", "-lc", "source ./scripts/release_asset_common.sh; pulse_release_prepare_signing_state pulse-installer pulse-install") + cmd.Dir = repoFile() + cmd.Env = append(os.Environ(), + "PULSE_UPDATE_SIGNING_KEY="+base64.StdEncoding.EncodeToString(privateKey), + "PULSE_UPDATE_SIGNING_PUBLIC_KEY="+base64.StdEncoding.EncodeToString(unexpectedPublicKey), + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("expected release_asset_common.sh to reject a mismatched signing public key:\n%s", output) + } + if !strings.Contains(string(output), "does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY") { + t.Fatalf("expected mismatched signing public key error, got:\n%s", output) + } +} + func repoFile(parts ...string) string { root := filepath.Join("..", "..") segments := append([]string{root}, parts...) diff --git a/scripts/release_asset_common.sh b/scripts/release_asset_common.sh index 3338ebb3d..9f484470f 100644 --- a/scripts/release_asset_common.sh +++ b/scripts/release_asset_common.sh @@ -12,10 +12,14 @@ pulse_release_go_run_update_key() { pulse_release_prepare_signing_state() { local signer_identity="${1:-pulse-installer}" local signer_namespace="${2:-pulse-install}" + local actual_fingerprint="" + local expected_public_key="" + local expected_fingerprint="" PULSE_RELEASE_SIGNER_IDENTITY="${signer_identity}" PULSE_RELEASE_SIGNER_NAMESPACE="${signer_namespace}" PULSE_RELEASE_UPDATE_PUBLIC_KEY="" + PULSE_RELEASE_UPDATE_PUBLIC_KEY_FINGERPRINT="" PULSE_RELEASE_UPDATE_SSH_PUBLIC_KEY="" PULSE_RELEASE_UPDATE_SSH_PRIVATE_KEY_FILE="" @@ -35,6 +39,36 @@ pulse_release_prepare_signing_state() { exit 1 fi + PULSE_RELEASE_UPDATE_PUBLIC_KEY_FINGERPRINT="$(pulse_release_go_run_update_key fingerprint --public-key "${PULSE_RELEASE_UPDATE_PUBLIC_KEY}")" + if [[ -z "${PULSE_RELEASE_UPDATE_PUBLIC_KEY_FINGERPRINT}" ]]; then + echo "Error: failed to derive update signing public key fingerprint." >&2 + exit 1 + fi + + expected_public_key="$(printf '%s' "${PULSE_UPDATE_SIGNING_PUBLIC_KEY:-}" | tr -d '\r\n[:space:]')" + if [[ -n "${expected_public_key}" && "${PULSE_RELEASE_UPDATE_PUBLIC_KEY}" != "${expected_public_key}" ]]; then + echo "Error: PULSE_UPDATE_SIGNING_KEY does not match PULSE_UPDATE_SIGNING_PUBLIC_KEY." >&2 + echo "Expected public key: ${expected_public_key}" >&2 + echo "Actual public key: ${PULSE_RELEASE_UPDATE_PUBLIC_KEY}" >&2 + exit 1 + fi + + expected_fingerprint="$(printf '%s' "${PULSE_UPDATE_SIGNING_PUBLIC_KEY_FINGERPRINT:-}" | tr -d '\r\n[:space:]')" + if [[ -n "${expected_fingerprint}" ]]; then + expected_fingerprint="${expected_fingerprint#SHA256:}" + actual_fingerprint="${PULSE_RELEASE_UPDATE_PUBLIC_KEY_FINGERPRINT#SHA256:}" + if [[ "${actual_fingerprint}" != "${expected_fingerprint}" ]]; then + echo "Error: PULSE_UPDATE_SIGNING_KEY fingerprint mismatch." >&2 + echo "Expected: SHA256:${expected_fingerprint}" >&2 + echo "Actual: ${PULSE_RELEASE_UPDATE_PUBLIC_KEY_FINGERPRINT}" >&2 + exit 1 + fi + fi + + if [[ -n "${expected_public_key}" || -n "${expected_fingerprint}" ]]; then + echo "Verified update signing public key fingerprint: ${PULSE_RELEASE_UPDATE_PUBLIC_KEY_FINGERPRINT}" + fi + if ! command -v ssh-keygen >/dev/null 2>&1; then echo "Error: ssh-keygen is required to sign installer release assets." >&2 exit 1 diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index e8ea810b2..ee570721c 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -277,6 +277,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}"', content) self.assertIn('./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}"', content) self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content) + self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content) + self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content) self.assertIn('SYFT_VERSION="1.42.4"', content) self.assertIn('SYFT_ARCHIVE="syft_${SYFT_VERSION}_linux_amd64.tar.gz"', content) self.assertIn('SYFT_SHA256="590650c2743b83f327d1bf9bec64f6f83b7fec504187bb84f500c862bf8f2a0f"', content) @@ -306,9 +308,19 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn(': "${PULSE_REPO_ROOT:=$(cd "${PULSE_SCRIPTS_DIR}/.." && pwd)}"', release_asset_helper) self.assertIn('go -C "${PULSE_REPO_ROOT}" run "${PULSE_SCRIPTS_DIR}/release_update_key.go" "$@"', release_asset_helper) self.assertIn('pulse_release_go_run_update_key public-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"', release_asset_helper) + self.assertIn( + 'pulse_release_go_run_update_key fingerprint --public-key "${PULSE_RELEASE_UPDATE_PUBLIC_KEY}"', + release_asset_helper, + ) self.assertIn('pulse_release_go_run_update_key public-key-ssh --private-key "${PULSE_UPDATE_SIGNING_KEY}"', release_asset_helper) self.assertIn('pulse_release_go_run_update_key openssh-private-key --private-key "${PULSE_UPDATE_SIGNING_KEY}"', release_asset_helper) self.assertIn('pulse_release_go_run_update_key sign --private-key "${PULSE_UPDATE_SIGNING_KEY}" --file "${absolute_file}"', release_asset_helper) + self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY", release_asset_helper) + self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY_FINGERPRINT", release_asset_helper) + self.assertIn( + "Verified update signing public key fingerprint: ${PULSE_RELEASE_UPDATE_PUBLIC_KEY_FINGERPRINT}", + release_asset_helper, + ) self.assertIn('"${resolved_tool}" "dir:${release_dir}" -o "spdx-json=${tmp_sbom}"', release_asset_helper) self.assertIn('find . -maxdepth 1 -type f \\( -name \'*.sig\' -o -name \'*.sshsig\' \\) -delete', release_asset_helper) self.assertIn('source "${SCRIPT_DIR}/release_asset_common.sh"', backfill_script) @@ -321,6 +333,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('SYFT_VERSION="1.42.4"', backfill_workflow) 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("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) @@ -397,6 +410,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("subject-name: ghcr.io/${{ github.repository_owner }}/pulse-agent", publish) self.assertIn("create-storage-record: false", 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) self.assertIn("control_plane.py --branch-for-version", promote) self.assertIn("control_plane.py --branch-for-version", demo) diff --git a/scripts/release_update_key.go b/scripts/release_update_key.go index 9080e7f5a..0219de33c 100644 --- a/scripts/release_update_key.go +++ b/scripts/release_update_key.go @@ -1,6 +1,10 @@ package main import ( + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" "flag" "fmt" "os" @@ -13,6 +17,7 @@ func usage() { fmt.Fprintln(os.Stderr, " release_update_key.go public-key --private-key ") fmt.Fprintln(os.Stderr, " release_update_key.go public-key-ssh --private-key [--comment ]") 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 ") os.Exit(1) } @@ -67,6 +72,39 @@ func main() { fail(err) } fmt.Print(encoded) + case "fingerprint": + fingerprintCmd := flag.NewFlagSet("fingerprint", flag.ExitOnError) + privateKey := fingerprintCmd.String("private-key", "", "base64-encoded Ed25519 private key or seed") + publicKey := fingerprintCmd.String("public-key", "", "base64-encoded Ed25519 public key or PKIX public key") + _ = fingerprintCmd.Parse(os.Args[2:]) + + if (*privateKey == "") == (*publicKey == "") { + usage() + } + + var ( + derivedPublicKey ed25519.PublicKey + err error + ) + if *privateKey != "" { + key, decodeErr := updatesignature.DecodePrivateKey(*privateKey) + if decodeErr != nil { + fail(decodeErr) + } + var ok bool + derivedPublicKey, ok = key.Public().(ed25519.PublicKey) + if !ok { + fail(fmt.Errorf("failed to derive Ed25519 public key")) + } + } else { + derivedPublicKey, err = decodePublicKey(*publicKey) + if err != nil { + fail(err) + } + } + + sum := sha256.Sum256(derivedPublicKey) + fmt.Printf("SHA256:%s\n", base64.StdEncoding.EncodeToString(sum[:])) case "sign": signCmd := flag.NewFlagSet("sign", flag.ExitOnError) privateKey := signCmd.String("private-key", "", "base64-encoded Ed25519 private key or seed") @@ -94,3 +132,23 @@ func fail(err error) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } + +func decodePublicKey(encoded string) (ed25519.PublicKey, error) { + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("invalid base64 public key: %w", err) + } + if len(decoded) == ed25519.PublicKeySize { + return ed25519.PublicKey(decoded), nil + } + + publicKey, err := x509.ParsePKIXPublicKey(decoded) + if err != nil { + return nil, fmt.Errorf("failed to parse Ed25519 public key: %w", err) + } + ed25519PublicKey, ok := publicKey.(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("trusted key is not an Ed25519 public key") + } + return ed25519PublicKey, nil +}