From d6e96ebeca7212375e1316aac7f5b4ddba3e688f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 5 May 2026 21:40:14 +0100 Subject: [PATCH] Fix v6 demo release signing key deployment --- .github/workflows/create-release.yml | 18 +++++++++++ .github/workflows/update-demo-server.yml | 14 ++++++++ .../subsystems/deployment-installability.md | 7 ++++ install.sh | 2 +- internal/updatesignature/signature.go | 15 ++++++++- .../installtests/build_release_assets_test.go | 32 +++++++++++++++++++ scripts/installtests/install_sh_test.go | 4 +-- scripts/installtests/root_install_sh_test.go | 4 +-- scripts/pulse-auto-update.sh | 2 +- .../release_promotion_policy_test.py | 7 ++++ scripts/release_update_key.go | 32 ++++++++++++++++--- 11 files changed, 125 insertions(+), 12 deletions(-) diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 0ded6ba04..7697d3d97 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -629,6 +629,24 @@ jobs: PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }} PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + - name: Validate installer signing key pins + env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + run: | + set -euo pipefail + TRUSTED_SSH_PUBLIC_KEY="$( + go run ./scripts/release_update_key.go public-key-ssh \ + --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ + --comment pulse-installer + )" + + for installer in install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh; do + grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" "${installer}" >/dev/null || { + echo "::error::${installer} does not trust the configured release signing key." + exit 1 + } + done + - name: Post-build health check run: | if [ -x ./pulse ]; then diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 97a8ce32a..c1f49ad7c 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -188,6 +188,11 @@ jobs: fetch-depth: 0 fetch-tags: true + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + - name: Wait for release assets run: | set -euo pipefail @@ -223,10 +228,19 @@ jobs: exit 1 - name: Materialize tagged installer + env: + PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} run: | set -euo pipefail TAG="${{ needs.resolve.outputs.tag }}" + TRUSTED_SSH_PUBLIC_KEY="$( + go run ./scripts/release_update_key.go public-key-ssh \ + --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ + --comment pulse-installer + )" git show "refs/tags/${TAG}:install.sh" > /tmp/pulse-install.sh + sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh + grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" /tmp/pulse-install.sh chmod +x /tmp/pulse-install.sh - name: Tailscale diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 0dcbbdfe7..846d7129c 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -193,6 +193,13 @@ server-side update execution surfaces. guard fixes discovered by the release workflow itself, must still be named in the audit record and reflected in the candidate commit hash, commit count, and changed-scope summary before the workflow is restarted. + Release workflows and demo-update workflows must derive the OpenSSH + installer trust key from `PULSE_UPDATE_SIGNING_PUBLIC_KEY`, not from a + duplicated hand-copied key. The release workflow must fail before + publication if the repo-root server installer or auto-update script does + not trust that configured signing key, and the demo-update workflow may + patch the derived trust key into an immutable historical tagged installer + copy before executing that installer for an already-published RC. A metadata-only packet refresh may identify the last validation commit that introduced release risk separately from the packet-refresh commit itself, but it must make that distinction explicit in the release notes and audit diff --git a/install.sh b/install.sh index 0aaf163a4..f48474eae 100755 --- a/install.sh +++ b/install.sh @@ -88,7 +88,7 @@ GITHUB_REPO="rcourtman/Pulse" DOCKER_IMAGE_REPO="${DOCKER_IMAGE_REPO:-rcourtman/pulse}" INSTALL_SIGNATURE_IDENTITY="pulse-installer" INSTALL_SIGNATURE_NAMESPACE="pulse-install" -PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV pulse-installer" +PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer" BUILD_FROM_SOURCE=false SKIP_DOWNLOAD=false IN_CONTAINER=false diff --git a/internal/updatesignature/signature.go b/internal/updatesignature/signature.go index 323fe2c13..2e27c864d 100644 --- a/internal/updatesignature/signature.go +++ b/internal/updatesignature/signature.go @@ -63,7 +63,20 @@ func AuthorizedPublicKeyString(privateKey ed25519.PrivateKey, comment string) (s if len(privateKey) != ed25519.PrivateKeySize { return "", errors.New("invalid signing key") } - publicKey, err := ssh.NewPublicKey(privateKey.Public()) + ed25519PublicKey, ok := privateKey.Public().(ed25519.PublicKey) + if !ok { + return "", errors.New("failed to derive public key") + } + return AuthorizedPublicKeyStringFromPublicKey(ed25519PublicKey, comment) +} + +// AuthorizedPublicKeyStringFromPublicKey returns the OpenSSH authorized_keys +// line for a raw Ed25519 public key. +func AuthorizedPublicKeyStringFromPublicKey(key ed25519.PublicKey, comment string) (string, error) { + if len(key) != ed25519.PublicKeySize { + return "", errors.New("invalid public key") + } + publicKey, err := ssh.NewPublicKey(key) if err != nil { return "", fmt.Errorf("derive ssh public key: %w", err) } diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index f9d0edff9..736580bd7 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -10,6 +10,8 @@ import ( "path/filepath" "strings" "testing" + + "golang.org/x/crypto/ssh" ) func TestBuildReleaseUsesV6InstallScripts(t *testing.T) { @@ -418,6 +420,10 @@ func TestReleaseWorkflowsUseSecretSafeAttestedImageBuilds(t *testing.T) { `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 }}`, + `Validate installer signing key pins`, + `go run ./scripts/release_update_key.go public-key-ssh`, + `install.sh scripts/pulse-auto-update.sh release/pulse-auto-update.sh`, + `does not trust the configured release signing 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`, @@ -564,6 +570,9 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `- name: Tailscale`, `uses: tailscale/github-action@4e4c49acaa9818630ce0bd7a564372c17e33fb4d # v2`, `authkey: ${{ secrets.TS_AUTHKEY }}`, + `uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5`, + `go run ./scripts/release_update_key.go public-key-ssh`, + `sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh`, `Verify target host identity`, `Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`, `Verify public browser smoke`, @@ -656,6 +665,29 @@ func TestReleaseUpdateKeyFingerprintUsesCanonicalRawPublicKeyHash(t *testing.T) } } +func TestReleaseUpdateKeyPublicKeySSHAcceptsPublicKey(t *testing.T) { + publicKey, _, 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", "public-key-ssh", "--public-key", base64.StdEncoding.EncodeToString(publicKey), "--comment", "pulse-installer") + cmd.Dir = repoFile() + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("release_update_key.go public-key-ssh failed: %v\n%s", err, output) + } + + sshPublicKey, err := ssh.NewPublicKey(publicKey) + if err != nil { + t.Fatalf("derive SSH public key: %v", err) + } + expected := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPublicKey))) + " pulse-installer" + if got := strings.TrimSpace(string(output)); got != expected { + t.Fatalf("SSH public key mismatch: got %q want %q", got, expected) + } +} + func TestReleaseAssetCommonRunsUpdateKeyThroughModulePath(t *testing.T) { if _, err := exec.LookPath("bash"); err != nil { t.Skip("bash not installed") diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 7409fe438..99313b75b 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -1984,7 +1984,7 @@ exit 0 print_info() { :; } INSTALL_SIGNATURE_IDENTITY="pulse-installer" INSTALL_SIGNATURE_NAMESPACE="pulse-install" - PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV pulse-installer" + PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer" ` + extractRootInstallShellFunction(t, "release_signature_key_available") + ` ` + extractRootInstallShellFunction(t, "require_release_signature_verifier") + ` ` + extractRootInstallShellFunction(t, "verify_release_signature") + ` @@ -2074,7 +2074,7 @@ esac systemctl() { return 0; } INSTALL_SIGNATURE_IDENTITY="pulse-installer" INSTALL_SIGNATURE_NAMESPACE="pulse-install" - PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV pulse-installer" + PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer" ` + extractAutoUpdateFunction(t, "release_signature_key_available") + ` ` + extractAutoUpdateFunction(t, "require_release_signature_verifier") + ` ` + extractAutoUpdateFunction(t, "verify_release_signature") + ` diff --git a/scripts/installtests/root_install_sh_test.go b/scripts/installtests/root_install_sh_test.go index 5f13a1534..54b9bf3f0 100644 --- a/scripts/installtests/root_install_sh_test.go +++ b/scripts/installtests/root_install_sh_test.go @@ -376,7 +376,7 @@ func TestRootInstallScriptRequiresSignedReleaseDownloads(t *testing.T) { script := string(content) required := []string{ - `PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV pulse-installer"`, + `PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer"`, `require_release_signature_verifier() {`, `verify_release_signature() {`, `local signature_url="${download_url}.sshsig"`, @@ -427,7 +427,7 @@ func TestPulseAutoUpdateScriptRequiresSignedInstallerDownloads(t *testing.T) { script := string(content) required := []string{ - `PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV pulse-installer"`, + `PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer"`, `require_release_signature_verifier() {`, `verify_release_signature() {`, `local install_signature_url="${install_script_url}.sshsig"`, diff --git a/scripts/pulse-auto-update.sh b/scripts/pulse-auto-update.sh index 13c7b9b72..2b655c8e5 100755 --- a/scripts/pulse-auto-update.sh +++ b/scripts/pulse-auto-update.sh @@ -16,7 +16,7 @@ LOG_TAG="${PULSE_AUTO_UPDATE_LOG_TAG:-${SERVICE_NAME}-auto-update}" MAX_LOG_SIZE=10485760 # 10MB INSTALL_SIGNATURE_IDENTITY="pulse-installer" INSTALL_SIGNATURE_NAMESPACE="pulse-install" -PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDs21c5oPk2khrdHlsw1aZ9EJKoTsyalGzhb0hdwJrkV pulse-installer" +PINNED_RELEASE_SSH_PUBLIC_KEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZd/DaH+BldzOkq1A8KVTcFk73nAyrE8aJOyf7i00jm pulse-installer" # Logging function log() { diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index f730fd40c..2ecca984a 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -337,6 +337,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): def test_release_workflow_enforces_rc_lineage_soak_and_v5_notice(self) -> None: content = read(".github/workflows/create-release.yml") + update_demo_workflow = read(".github/workflows/update-demo-server.yml") validation_workflow = read(".github/workflows/validate-release-assets.yml") helper = read("scripts/trigger-release.sh") renderer = read("scripts/release_control/render_release_body.py") @@ -410,6 +411,12 @@ class ReleasePromotionPolicyTest(unittest.TestCase): 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("Validate installer signing key pins", content) + self.assertIn("go run ./scripts/release_update_key.go public-key-ssh", content) + self.assertIn("does not trust the configured release signing key", content) + self.assertIn("TRUSTED_SSH_PUBLIC_KEY", update_demo_workflow) + self.assertIn('sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\\"${TRUSTED_SSH_PUBLIC_KEY}\\"|" /tmp/pulse-install.sh', update_demo_workflow) + self.assertIn("derive the OpenSSH installer trust key from `PULSE_UPDATE_SIGNING_PUBLIC_KEY`", normalize_ws(contract)) 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) diff --git a/scripts/release_update_key.go b/scripts/release_update_key.go index a53b7af61..ef6f9faff 100644 --- a/scripts/release_update_key.go +++ b/scripts/release_update_key.go @@ -17,7 +17,7 @@ import ( func usage() { fmt.Fprintln(os.Stderr, "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 public-key-ssh (--private-key | --public-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 ") @@ -47,14 +47,36 @@ func main() { case "public-key-ssh": publicKeyCmd := flag.NewFlagSet("public-key-ssh", flag.ExitOnError) privateKey := publicKeyCmd.String("private-key", "", "base64-encoded Ed25519 private key or seed") + publicKey := publicKeyCmd.String("public-key", "", "base64-encoded Ed25519 public key or PKIX public key") comment := publicKeyCmd.String("comment", "", "optional SSH key comment") _ = publicKeyCmd.Parse(os.Args[2:]) - key, err := updatesignature.DecodePrivateKey(*privateKey) - if err != nil { - fail(err) + if (*privateKey == "") == (*publicKey == "") { + usage() } - encoded, err := updatesignature.AuthorizedPublicKeyString(key, *comment) + + 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) + } + } + + encoded, err := updatesignature.AuthorizedPublicKeyStringFromPublicKey(derivedPublicKey, *comment) if err != nil { fail(err) }