Guard forward release signing against trust-root drift

This commit is contained in:
rcourtman 2026-04-22 19:59:18 +01:00
parent df04b9b1ff
commit f58840e8a8
11 changed files with 207 additions and 6 deletions

View file

@ -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 }}"

View file

@ -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 }}"

View file

@ -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 }}

View file

@ -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 \

View file

@ -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.

View file

@ -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`

View file

@ -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

View file

@ -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...)

View file

@ -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

View file

@ -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)

View file

@ -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 <base64-ed25519-key-or-seed>")
fmt.Fprintln(os.Stderr, " release_update_key.go public-key-ssh --private-key <base64-ed25519-key-or-seed> [--comment <comment>]")
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>")
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
}