From 6a03fb4b9d7830157ad58e53c3cdff3a7b9e0d35 Mon Sep 17 00:00:00 2001 From: Ashish Kurmi Date: Sun, 24 May 2026 03:15:49 -0700 Subject: [PATCH] ci: Authenticode-sign Windows binaries and MSIs via Azure Trusted Signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Authenticode signing for Windows .exe and .msi artifacts as part of the release workflow, using Azure Trusted Signing via OIDC federation. Sigstore signing for Windows artifacts moves post-Authenticode so cosign bundles cover the bytes users actually download. - release.yml: new windows-sign-and-package job gated on the `release` GitHub Environment (two reviewers, main-only). Workflow signs the 4 Windows .exes (agent + launcher × amd64 + arm64), builds 2 MSIs from the signed exes, Authenticode-signs the MSIs, then sigstore-signs everything and attests build provenance. Uses azure/login OIDC + the Azure/trusted-signing-action. cache-dependencies: false works around a same-job silent-exit bug when the action runs twice. - test-azure-signing.yml: new manual-trigger smoke test for the OIDC -> Azure -> signtool path. Builds throwaway snapshot binaries, signs and verifies, uploads signed artifacts for offline inspection. Does not tag or touch any release. - docs/release-process.md: documents the approval gate, the new Windows verification commands (Get-AuthenticodeSignature + signtool verify /pa + cosign + gh attestation verify), and the Authenticode + RFC3161 timestamp guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 254 ++++++++++++++---- .github/workflows/test-azure-signing.yml | 327 +++++++++++++++++++++++ docs/release-process.md | 80 +++++- 3 files changed, 599 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/test-azure-signing.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fda7c70..b56ecef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,14 +91,12 @@ jobs: - name: Locate binaries and packages id: binaries run: | + # Windows .exes are uploaded to the draft release directly by GoReleaser + # (formats: binary keeps the raw build outputs in dist/__/ + # subdirs without renaming, only the upload uses the templated name). + # The windows-sign-and-package job downloads them from the draft release, + # so we don't need to locate them locally here. DARWIN=$(find dist -type f -name '*darwin_unnotarized' | head -1) - # Filter agent vs launcher (-task) explicitly — both ship as - # --_.exe so a bare *.exe glob would - # match either. - WIN_AMD64=$(find dist -type f -name 'stepsecurity-dev-machine-guard-*-windows_amd64.exe' ! -name '*-task-*' | head -1) - WIN_ARM64=$(find dist -type f -name 'stepsecurity-dev-machine-guard-*-windows_arm64.exe' ! -name '*-task-*' | head -1) - WIN_TASK_AMD64=$(find dist -type f -name 'stepsecurity-dev-machine-guard-task-*-windows_amd64.exe' | head -1) - WIN_TASK_ARM64=$(find dist -type f -name 'stepsecurity-dev-machine-guard-task-*-windows_arm64.exe' | head -1) LINUX_AMD64=$(find dist -type f -name 'stepsecurity-dev-machine-guard' -path '*linux_amd64*' | head -1) LINUX_ARM64=$(find dist -type f -name 'stepsecurity-dev-machine-guard' -path '*linux_arm64*' | head -1) @@ -107,7 +105,7 @@ jobs: RPM_AMD64=$(find dist -type f -name '*-amd64.rpm' | head -1) RPM_ARM64=$(find dist -type f -name '*-arm64.rpm' | head -1) - for label in "darwin:${DARWIN}" "windows_amd64:${WIN_AMD64}" "windows_arm64:${WIN_ARM64}" "windows_task_amd64:${WIN_TASK_AMD64}" "windows_task_arm64:${WIN_TASK_ARM64}" "linux_amd64:${LINUX_AMD64}" "linux_arm64:${LINUX_ARM64}" "deb_amd64:${DEB_AMD64}" "deb_arm64:${DEB_ARM64}" "rpm_amd64:${RPM_AMD64}" "rpm_arm64:${RPM_ARM64}"; do + for label in "darwin:${DARWIN}" "linux_amd64:${LINUX_AMD64}" "linux_arm64:${LINUX_ARM64}" "deb_amd64:${DEB_AMD64}" "deb_arm64:${DEB_ARM64}" "rpm_amd64:${RPM_AMD64}" "rpm_arm64:${RPM_ARM64}"; do name="${label%%:*}" path="${label#*:}" if [ -z "$path" ] || [ ! -f "$path" ]; then @@ -118,10 +116,6 @@ jobs: done echo "darwin=$DARWIN" >> "$GITHUB_OUTPUT" - echo "win_amd64=$WIN_AMD64" >> "$GITHUB_OUTPUT" - echo "win_arm64=$WIN_ARM64" >> "$GITHUB_OUTPUT" - echo "win_task_amd64=$WIN_TASK_AMD64" >> "$GITHUB_OUTPUT" - echo "win_task_arm64=$WIN_TASK_ARM64" >> "$GITHUB_OUTPUT" echo "linux_amd64=$LINUX_AMD64" >> "$GITHUB_OUTPUT" echo "linux_arm64=$LINUX_ARM64" >> "$GITHUB_OUTPUT" echo "deb_amd64=$DEB_AMD64" >> "$GITHUB_OUTPUT" @@ -148,16 +142,11 @@ jobs: return 1 } + # Windows .exes are signed in the windows-sign-and-package job after + # Authenticode signing, so the cosign bundles match the bytes users + # download from the published release. sign_with_retry "${{ steps.binaries.outputs.darwin }}" \ "dist/stepsecurity-dev-machine-guard-darwin_unnotarized.bundle" - sign_with_retry "${{ steps.binaries.outputs.win_amd64 }}" \ - "dist/stepsecurity-dev-machine-guard-windows_amd64.exe.bundle" - sign_with_retry "${{ steps.binaries.outputs.win_arm64 }}" \ - "dist/stepsecurity-dev-machine-guard-windows_arm64.exe.bundle" - sign_with_retry "${{ steps.binaries.outputs.win_task_amd64 }}" \ - "dist/stepsecurity-dev-machine-guard-task-windows_amd64.exe.bundle" - sign_with_retry "${{ steps.binaries.outputs.win_task_arm64 }}" \ - "dist/stepsecurity-dev-machine-guard-task-windows_arm64.exe.bundle" sign_with_retry "${{ steps.binaries.outputs.linux_amd64 }}" \ "dist/stepsecurity-dev-machine-guard-linux_amd64.bundle" sign_with_retry "${{ steps.binaries.outputs.linux_arm64 }}" \ @@ -175,12 +164,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Windows .exe bundles are uploaded by the windows-sign-and-package job. gh release upload "${{ steps.release.outputs.tag }}" \ dist/stepsecurity-dev-machine-guard-darwin_unnotarized.bundle \ - dist/stepsecurity-dev-machine-guard-windows_amd64.exe.bundle \ - dist/stepsecurity-dev-machine-guard-windows_arm64.exe.bundle \ - dist/stepsecurity-dev-machine-guard-task-windows_amd64.exe.bundle \ - dist/stepsecurity-dev-machine-guard-task-windows_arm64.exe.bundle \ dist/stepsecurity-dev-machine-guard-linux_amd64.bundle \ dist/stepsecurity-dev-machine-guard-linux_arm64.bundle \ "${{ steps.binaries.outputs.deb_amd64 }}.bundle" \ @@ -190,14 +176,12 @@ jobs: --clobber - name: Attest build provenance + # Windows .exe and MSI attestations are emitted by the + # windows-sign-and-package job after Authenticode signing. uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: | ${{ steps.binaries.outputs.darwin }} - ${{ steps.binaries.outputs.win_amd64 }} - ${{ steps.binaries.outputs.win_arm64 }} - ${{ steps.binaries.outputs.win_task_amd64 }} - ${{ steps.binaries.outputs.win_task_arm64 }} ${{ steps.binaries.outputs.linux_amd64 }} ${{ steps.binaries.outputs.linux_arm64 }} ${{ steps.binaries.outputs.deb_amd64 }} @@ -205,10 +189,15 @@ jobs: ${{ steps.binaries.outputs.rpm_amd64 }} ${{ steps.binaries.outputs.rpm_arm64 }} - build-msi: - name: Build & Sign MSIs + windows-sign-and-package: + name: Sign Windows binaries, build & sign MSIs needs: release runs-on: windows-latest + # The `release` environment (lowercase — must match the GitHub Environment + # name exactly) requires two reviewers and is restricted to main. The Azure + # Federated Identity Credential is bound to this environment's OIDC subject, + # so azure/login below only works from jobs that gate on this environment. + environment: release permissions: contents: write id-token: write @@ -216,7 +205,11 @@ jobs: steps: - name: Harden the runner (Audit all outbound calls) - uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 + # trusted-signing-action talks to wus2.codesigning.azure.net, + # login.microsoftonline.com, timestamp.acs.microsoft.com, and Azure + # storage CDNs. Audit mode logs warnings without blocking — capture + # the full endpoint list before switching to block mode. + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit @@ -224,9 +217,6 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install WiX 4 + Util extension - # WiX 4 ships as a .NET global tool. The Util extension (WixQuietExec - # and friends) is a separate NuGet package that must be added to the - # global wix tool before referencing util: namespace types. shell: pwsh run: | dotnet tool install --global wix --version 4.0.5 @@ -241,9 +231,6 @@ jobs: run: | $tag = "${{ needs.release.outputs.release_tag }}" New-Item -ItemType Directory -Path dist -Force | Out-Null - # Goreleaser produces archive names like: - # stepsecurity-dev-machine-guard--windows_amd64.exe - # We download them by exact pattern to dist/. gh release download "$tag" ` -R "${{ github.repository }}" ` -p "*-windows_amd64.exe" ` @@ -251,29 +238,152 @@ jobs: -D dist Get-ChildItem dist | Format-Table Name, Length - - name: Build MSIs (x64 + arm64) + - name: Resolve Windows binary paths + id: paths shell: pwsh run: | - $version = "${{ needs.release.outputs.version }}" - # Both agent and launcher .exes share the *-windows_.exe - # suffix; filter on the -task- segment to tell them apart. - $amd64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-*-windows_amd64.exe" | Where-Object Name -NotLike '*-task-*' | Select-Object -First 1 - $arm64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-*-windows_arm64.exe" | Where-Object Name -NotLike '*-task-*' | Select-Object -First 1 - $taskAmd64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-task-*-windows_amd64.exe" | Select-Object -First 1 - $taskArm64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-task-*-windows_arm64.exe" | Select-Object -First 1 + $ErrorActionPreference = 'Stop' + $amd64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-*-windows_amd64.exe" | Where-Object Name -NotLike '*-task-*' | Select-Object -First 1 + $arm64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-*-windows_arm64.exe" | Where-Object Name -NotLike '*-task-*' | Select-Object -First 1 + $taskAmd64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-task-*-windows_amd64.exe" | Select-Object -First 1 + $taskArm64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-task-*-windows_arm64.exe" | Select-Object -First 1 if (-not $amd64 -or -not $arm64 -or -not $taskAmd64 -or -not $taskArm64) { Write-Error "Windows .exe assets (agent + launcher, both arches) missing under dist/" Get-ChildItem dist | Format-Table Name exit 1 } + # trusted-signing-action v2 requires absolute (rooted) paths in `files:`. + "amd64=$($amd64.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "arm64=$($arm64.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "task_amd64=$($taskAmd64.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "task_arm64=$($taskArm64.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Azure login (OIDC) + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Authenticode-sign Windows .exes + uses: Azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ secrets.AZURE_TS_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_TS_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TS_CERT_PROFILE_NAME }} + files: | + ${{ steps.paths.outputs.amd64 }} + ${{ steps.paths.outputs.arm64 }} + ${{ steps.paths.outputs.task_amd64 }} + ${{ steps.paths.outputs.task_arm64 }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + # Disable the action's same-job module cache to avoid a silent-exit + # bug when this action runs twice in the same job. See "Authenticode-sign MSIs" + # below — when both invocations get same-key cache hits, the second + # invocation dies without logging an error. + cache-dependencies: false + + - name: Verify .exe signatures + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $failed = $false + foreach ($f in @( + '${{ steps.paths.outputs.amd64 }}', + '${{ steps.paths.outputs.arm64 }}', + '${{ steps.paths.outputs.task_amd64 }}', + '${{ steps.paths.outputs.task_arm64 }}' + )) { + $sig = Get-AuthenticodeSignature $f + $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' } + Write-Host "$(Split-Path $f -Leaf): Status=$($sig.Status), Signer=$subject" + if ($sig.Status -ne 'Valid') { + Write-Host "::error::Signature status is $($sig.Status) (expected Valid) for $f" + $failed = $true + } + if (-not $sig.TimeStamperCertificate) { + Write-Host "::error::No RFC3161 timestamp on $f" + $failed = $true + } + } + if ($failed) { exit 1 } + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Sign signed .exes with Sigstore + shell: bash + run: | + set -euo pipefail + version="${{ needs.release.outputs.version }}" + + sign_with_retry() { + local blob="$1" + local bundle="$2" + for attempt in 1 2 3; do + if cosign sign-blob "$blob" --bundle "$bundle" --yes; then + return 0 + fi + echo "::warning::Signing attempt $attempt failed for $(basename "$blob"), retrying in 10s..." + sleep 10 + done + echo "::error::Signing failed for $(basename "$blob") after 3 attempts" + return 1 + } + + sign_with_retry "dist/stepsecurity-dev-machine-guard-${version}-windows_amd64.exe" \ + "dist/stepsecurity-dev-machine-guard-windows_amd64.exe.bundle" + sign_with_retry "dist/stepsecurity-dev-machine-guard-${version}-windows_arm64.exe" \ + "dist/stepsecurity-dev-machine-guard-windows_arm64.exe.bundle" + sign_with_retry "dist/stepsecurity-dev-machine-guard-task-${version}-windows_amd64.exe" \ + "dist/stepsecurity-dev-machine-guard-task-windows_amd64.exe.bundle" + sign_with_retry "dist/stepsecurity-dev-machine-guard-task-${version}-windows_arm64.exe" \ + "dist/stepsecurity-dev-machine-guard-task-windows_arm64.exe.bundle" + + - name: Upload signed .exes and bundles to draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + tag="${{ needs.release.outputs.release_tag }}" + version="${{ needs.release.outputs.version }}" + gh release upload "$tag" \ + "dist/stepsecurity-dev-machine-guard-${version}-windows_amd64.exe" \ + "dist/stepsecurity-dev-machine-guard-${version}-windows_arm64.exe" \ + "dist/stepsecurity-dev-machine-guard-task-${version}-windows_amd64.exe" \ + "dist/stepsecurity-dev-machine-guard-task-${version}-windows_arm64.exe" \ + dist/stepsecurity-dev-machine-guard-windows_amd64.exe.bundle \ + dist/stepsecurity-dev-machine-guard-windows_arm64.exe.bundle \ + dist/stepsecurity-dev-machine-guard-task-windows_amd64.exe.bundle \ + dist/stepsecurity-dev-machine-guard-task-windows_arm64.exe.bundle \ + --clobber + + - name: Attest .exe build provenance + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: | + ${{ steps.paths.outputs.amd64 }} + ${{ steps.paths.outputs.arm64 }} + ${{ steps.paths.outputs.task_amd64 }} + ${{ steps.paths.outputs.task_arm64 }} + + - name: Build MSIs (x64 + arm64) + id: msi + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $version = "${{ needs.release.outputs.version }}" wix build packaging/windows/Product.wxs ` -arch x64 ` -ext WixToolset.Util.wixext ` -d Arch=x64 ` -d "Version=$version" ` - -d "BinaryPath=$($amd64.FullName)" ` - -d "LauncherPath=$($taskAmd64.FullName)" ` + -d "BinaryPath=${{ steps.paths.outputs.amd64 }}" ` + -d "LauncherPath=${{ steps.paths.outputs.task_amd64 }}" ` -out "dist/stepsecurity-dev-machine-guard-$version-x64.msi" wix build packaging/windows/Product.wxs ` @@ -281,14 +391,52 @@ jobs: -ext WixToolset.Util.wixext ` -d Arch=arm64 ` -d "Version=$version" ` - -d "BinaryPath=$($arm64.FullName)" ` - -d "LauncherPath=$($taskArm64.FullName)" ` + -d "BinaryPath=${{ steps.paths.outputs.arm64 }}" ` + -d "LauncherPath=${{ steps.paths.outputs.task_arm64 }}" ` -out "dist/stepsecurity-dev-machine-guard-$version-arm64.msi" + $x64 = (Get-Item "dist/stepsecurity-dev-machine-guard-$version-x64.msi").FullName + $arm64 = (Get-Item "dist/stepsecurity-dev-machine-guard-$version-arm64.msi").FullName + "x64=$x64" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "arm64=$arm64" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + Get-ChildItem dist -Filter "*.msi" | Format-Table Name, Length - - name: Install cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + - name: Authenticode-sign MSIs + uses: Azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ secrets.AZURE_TS_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_TS_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TS_CERT_PROFILE_NAME }} + files: | + ${{ steps.msi.outputs.x64 }} + ${{ steps.msi.outputs.arm64 }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + # Same workaround as the .exe sign step above — disable in-job cache + # to avoid the silent-exit bug when both invocations get cache hits. + cache-dependencies: false + + - name: Verify MSI signatures + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $failed = $false + foreach ($msi in @('${{ steps.msi.outputs.x64 }}', '${{ steps.msi.outputs.arm64 }}')) { + $sig = Get-AuthenticodeSignature $msi + $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' } + Write-Host "$(Split-Path $msi -Leaf): Status=$($sig.Status), Signer=$subject" + if ($sig.Status -ne 'Valid') { + Write-Host "::error::MSI signature status is $($sig.Status) (expected Valid) for $msi" + $failed = $true + } + if (-not $sig.TimeStamperCertificate) { + Write-Host "::error::No RFC3161 timestamp on $msi" + $failed = $true + } + } + if ($failed) { exit 1 } - name: Sign MSIs with Sigstore shell: bash @@ -328,5 +476,5 @@ jobs: uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 with: subject-path: | - dist/stepsecurity-dev-machine-guard-${{ needs.release.outputs.version }}-x64.msi - dist/stepsecurity-dev-machine-guard-${{ needs.release.outputs.version }}-arm64.msi + ${{ steps.msi.outputs.x64 }} + ${{ steps.msi.outputs.arm64 }} diff --git a/.github/workflows/test-azure-signing.yml b/.github/workflows/test-azure-signing.yml new file mode 100644 index 0000000..67eb5e3 --- /dev/null +++ b/.github/workflows/test-azure-signing.yml @@ -0,0 +1,327 @@ +name: Test Azure Trusted Signing + +on: + workflow_dispatch: + +permissions: {} + +jobs: + build: + name: Build snapshot Windows binaries + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Extract version from source + id: version + run: | + version=$(grep -m1 'Version.*=' internal/buildinfo/version.go | sed 's/.*"\(.*\)".*/\1/') + if [ -z "$version" ]; then + echo "::error::Could not extract Version from internal/buildinfo/version.go" + exit 1 + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Run GoReleaser (snapshot, no publish) + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 + with: + distribution: goreleaser + version: latest + args: release --snapshot --clean --skip=publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Stage Windows .exes with release-style names + run: | + set -euo pipefail + version="${{ steps.version.outputs.version }}" + mkdir -p staging + + find_one() { + local result + result=$(find dist -type f "$@" | head -1) + if [ -z "$result" ] || [ ! -f "$result" ]; then + echo "::error::No file matched: $*" + find dist -type f + exit 1 + fi + printf '%s\n' "$result" + } + + win_amd64_src=$(find_one -name 'stepsecurity-dev-machine-guard.exe' -path '*windows_amd64*') + win_arm64_src=$(find_one -name 'stepsecurity-dev-machine-guard.exe' -path '*windows_arm64*') + win_task_amd64_src=$(find_one -name 'stepsecurity-dev-machine-guard-task.exe' -path '*windows_amd64*') + win_task_arm64_src=$(find_one -name 'stepsecurity-dev-machine-guard-task.exe' -path '*windows_arm64*') + + cp "$win_amd64_src" "staging/stepsecurity-dev-machine-guard-${version}-windows_amd64.exe" + cp "$win_arm64_src" "staging/stepsecurity-dev-machine-guard-${version}-windows_arm64.exe" + cp "$win_task_amd64_src" "staging/stepsecurity-dev-machine-guard-task-${version}-windows_amd64.exe" + cp "$win_task_arm64_src" "staging/stepsecurity-dev-machine-guard-task-${version}-windows_arm64.exe" + ls -la staging/ + + - name: Upload Windows .exes + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-exes + path: | + staging/stepsecurity-dev-machine-guard-*-windows_amd64.exe + staging/stepsecurity-dev-machine-guard-*-windows_arm64.exe + staging/stepsecurity-dev-machine-guard-task-*-windows_amd64.exe + staging/stepsecurity-dev-machine-guard-task-*-windows_arm64.exe + if-no-files-found: error + + sign-and-verify: + name: Authenticode-sign & verify + needs: build + runs-on: windows-latest + environment: release + permissions: + id-token: write + contents: read + + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install WiX 4 + Util extension + shell: pwsh + run: | + dotnet tool install --global wix --version 4.0.5 + wix --version + wix extension add --global WixToolset.Util.wixext/4.0.5 + wix extension list --global + + - name: Download Windows .exes from build job + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: windows-exes + path: dist + + - name: Resolve Windows binary paths + id: paths + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $amd64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-*-windows_amd64.exe" | Where-Object Name -NotLike '*-task-*' | Select-Object -First 1 + $arm64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-*-windows_arm64.exe" | Where-Object Name -NotLike '*-task-*' | Select-Object -First 1 + $taskAmd64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-task-*-windows_amd64.exe" | Select-Object -First 1 + $taskArm64 = Get-ChildItem dist -Filter "stepsecurity-dev-machine-guard-task-*-windows_arm64.exe" | Select-Object -First 1 + if (-not $amd64 -or -not $arm64 -or -not $taskAmd64 -or -not $taskArm64) { + Write-Error "One or more Windows .exes missing under dist/" + Get-ChildItem dist | Format-Table Name + exit 1 + } + "amd64=$($amd64.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "arm64=$($arm64.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "task_amd64=$($taskAmd64.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "task_arm64=$($taskArm64.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Show pre-signing signature state (sanity) + shell: pwsh + run: | + foreach ($f in @( + '${{ steps.paths.outputs.amd64 }}', + '${{ steps.paths.outputs.arm64 }}', + '${{ steps.paths.outputs.task_amd64 }}', + '${{ steps.paths.outputs.task_arm64 }}' + )) { + $sig = Get-AuthenticodeSignature $f + Write-Host "$f -> $($sig.Status)" + if ($sig.Status -eq 'Valid') { + Write-Error "Pre-signing check: $f is already signed. Aborting so we don't mask a regression." + exit 1 + } + } + + - name: Azure login (OIDC) + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Authenticode-sign Windows .exes + uses: Azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ secrets.AZURE_TS_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_TS_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TS_CERT_PROFILE_NAME }} + files: | + ${{ steps.paths.outputs.amd64 }} + ${{ steps.paths.outputs.arm64 }} + ${{ steps.paths.outputs.task_amd64 }} + ${{ steps.paths.outputs.task_arm64 }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify .exe signatures + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $failed = $false + foreach ($f in @( + '${{ steps.paths.outputs.amd64 }}', + '${{ steps.paths.outputs.arm64 }}', + '${{ steps.paths.outputs.task_amd64 }}', + '${{ steps.paths.outputs.task_arm64 }}' + )) { + $sig = Get-AuthenticodeSignature $f + $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' } + $timeSubject = if ($sig.TimeStamperCertificate) { $sig.TimeStamperCertificate.Subject } else { '' } + Write-Host "$(Split-Path $f -Leaf):" + Write-Host " Status: $($sig.Status)" + Write-Host " Signer: $subject" + Write-Host " Timestamper: $timeSubject" + if ($sig.Status -ne 'Valid') { + Write-Host "::error::Signature status is $($sig.Status) (expected Valid)" + $failed = $true + } + if ($subject -notmatch 'Step\s*Security') { + Write-Host "::warning::Signer subject does not contain 'Step Security': $subject" + } + if (-not $sig.TimeStamperCertificate) { + Write-Host "::error::No timestamp on $f — Authenticode signature will expire with the cert" + $failed = $true + } + } + if ($failed) { exit 1 } + + - name: Build MSIs from signed exes (x64 + arm64) + id: msi + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $version = "${{ needs.build.outputs.version }}" + + wix build packaging/windows/Product.wxs ` + -arch x64 ` + -ext WixToolset.Util.wixext ` + -d Arch=x64 ` + -d "Version=$version" ` + -d "BinaryPath=${{ steps.paths.outputs.amd64 }}" ` + -d "LauncherPath=${{ steps.paths.outputs.task_amd64 }}" ` + -out "dist/stepsecurity-dev-machine-guard-$version-x64.msi" + + wix build packaging/windows/Product.wxs ` + -arch arm64 ` + -ext WixToolset.Util.wixext ` + -d Arch=arm64 ` + -d "Version=$version" ` + -d "BinaryPath=${{ steps.paths.outputs.arm64 }}" ` + -d "LauncherPath=${{ steps.paths.outputs.task_arm64 }}" ` + -out "dist/stepsecurity-dev-machine-guard-$version-arm64.msi" + + # trusted-signing-action v2 requires absolute (rooted) paths in `files:`. + # Resolve to .FullName here so both the sign and verify steps below use + # the same rooted path. + $x64 = (Get-Item "dist/stepsecurity-dev-machine-guard-$version-x64.msi").FullName + $arm64 = (Get-Item "dist/stepsecurity-dev-machine-guard-$version-arm64.msi").FullName + "x64=$x64" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "arm64=$arm64" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + Get-ChildItem dist -Filter "*.msi" | Format-Table Name, Length + + - name: Authenticode-sign MSIs + uses: Azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ secrets.AZURE_TS_ENDPOINT }} + signing-account-name: ${{ secrets.AZURE_TS_ACCOUNT_NAME }} + certificate-profile-name: ${{ secrets.AZURE_TS_CERT_PROFILE_NAME }} + files: | + ${{ steps.msi.outputs.x64 }} + ${{ steps.msi.outputs.arm64 }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify MSI signatures + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $version = "${{ needs.build.outputs.version }}" + $failed = $false + foreach ($msi in @( + "dist/stepsecurity-dev-machine-guard-$version-x64.msi", + "dist/stepsecurity-dev-machine-guard-$version-arm64.msi" + )) { + $sig = Get-AuthenticodeSignature $msi + $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' } + $timeSubject = if ($sig.TimeStamperCertificate) { $sig.TimeStamperCertificate.Subject } else { '' } + Write-Host "$(Split-Path $msi -Leaf):" + Write-Host " Status: $($sig.Status)" + Write-Host " Signer: $subject" + Write-Host " Timestamper: $timeSubject" + if ($sig.Status -ne 'Valid') { + Write-Host "::error::MSI signature status is $($sig.Status) (expected Valid)" + $failed = $true + } + if (-not $sig.TimeStamperCertificate) { + Write-Host "::error::No timestamp on $msi" + $failed = $true + } + } + if ($failed) { exit 1 } + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Sigstore-sign one signed .exe and one signed .msi (smoke test) + shell: bash + run: | + set -euo pipefail + version="${{ needs.build.outputs.version }}" + exe="dist/stepsecurity-dev-machine-guard-${version}-windows_amd64.exe" + msi="dist/stepsecurity-dev-machine-guard-${version}-x64.msi" + + # Resolved path may differ from the staging copy; fall back to a find + # if the literal path is missing. + if [ ! -f "$exe" ]; then + exe=$(find dist -type f -name "stepsecurity-dev-machine-guard-${version}-windows_amd64.exe" | head -1) + fi + test -f "$exe" || { echo "::error::Signed amd64 exe not found"; exit 1; } + test -f "$msi" || { echo "::error::Signed x64 MSI not found"; exit 1; } + + cosign sign-blob "$exe" --bundle "${exe}.bundle" --yes + cosign sign-blob "$msi" --bundle "${msi}.bundle" --yes + + test -s "${exe}.bundle" || { echo "::error::Empty cosign bundle for $exe"; exit 1; } + test -s "${msi}.bundle" || { echo "::error::Empty cosign bundle for $msi"; exit 1; } + echo "cosign bundles produced for post-Authenticode bytes." + + - name: Upload signed artifacts for inspection + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: signed-artifacts + path: | + dist/stepsecurity-dev-machine-guard-*-windows_amd64.exe + dist/stepsecurity-dev-machine-guard-*-windows_arm64.exe + dist/stepsecurity-dev-machine-guard-task-*-windows_amd64.exe + dist/stepsecurity-dev-machine-guard-task-*-windows_arm64.exe + dist/stepsecurity-dev-machine-guard-*-x64.msi + dist/stepsecurity-dev-machine-guard-*-arm64.msi + dist/*.bundle + if-no-files-found: error diff --git a/docs/release-process.md b/docs/release-process.md index f712515..c3c0e37 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -10,7 +10,7 @@ This document describes how releases are created, signed, notarized, and verifie Releases are a two-phase process: -1. **CI (automated)** — GitHub Actions builds the universal macOS binary, Windows binaries (amd64 + arm64), and Linux binaries (amd64 + arm64), signs them all with Sigstore, and creates a **draft** release. +1. **CI (automated, gated)** — GitHub Actions builds binaries for all platforms, Authenticode-signs the Windows `.exe`s and `.msi`s via Azure Trusted Signing, Sigstore-signs every artifact (after Authenticode for Windows, so cosign bundles match the bytes users download), creates a **draft** release, and emits SLSA build provenance attestations. The Windows signing job runs in the `release` GitHub Environment, which requires two reviewers and is restricted to `main`. 2. **Apple notarization (manual)** — Download the macOS binary, sign and notarize it with an Apple Developer account, upload the notarized binary to the draft release, and publish. --- @@ -36,12 +36,16 @@ The workflow will: - Create a git tag (`v1.9.1`) - Build via GoReleaser: - Universal macOS binary (amd64 + arm64) - - Windows binaries (amd64 + arm64) + - Windows binaries (amd64 + arm64; agent + launcher) - Linux binaries (amd64 + arm64) -- Sign all artifacts with Sigstore cosign (keyless) + - Build MSIs (x64 + arm64) from the signed Windows binaries +- Authenticode-sign Windows `.exe`s and `.msi`s via Azure Trusted Signing (with RFC3161 timestamp from Microsoft) +- Sign all artifacts with Sigstore cosign (keyless); Windows cosign bundles cover the post-Authenticode bytes - Upload to a **draft** release - Generate SLSA build provenance attestation +**Approval gate**: the Windows signing job waits at the `release` environment — two reviewers must approve before signing runs. The job won't start until the macOS/Linux portion finishes the draft upload, so the macOS notarization step below can run in parallel with reviewers approving. + ### 3. Apple notarization (manual) On a Mac with the Apple Developer certificate installed: @@ -89,10 +93,18 @@ Each release includes: | `stepsecurity-dev-machine-guard-VERSION-darwin` | Notarized universal macOS binary (amd64 + arm64) | | `stepsecurity-dev-machine-guard-VERSION-darwin_unnotarized` | Original CI-built binary (for provenance verification) | | `stepsecurity-dev-machine-guard-darwin_unnotarized.bundle` | Sigstore cosign bundle for the unnotarized binary | -| `stepsecurity-dev-machine-guard-VERSION-windows_amd64.exe` | Windows 64-bit binary | -| `stepsecurity-dev-machine-guard-windows_amd64.exe.bundle` | Sigstore cosign bundle for the Windows amd64 binary | -| `stepsecurity-dev-machine-guard-VERSION-windows_arm64.exe` | Windows ARM64 binary | -| `stepsecurity-dev-machine-guard-windows_arm64.exe.bundle` | Sigstore cosign bundle for the Windows arm64 binary | +| `stepsecurity-dev-machine-guard-VERSION-windows_amd64.exe` | Authenticode-signed Windows 64-bit agent | +| `stepsecurity-dev-machine-guard-windows_amd64.exe.bundle` | Sigstore cosign bundle (covers the signed bytes) | +| `stepsecurity-dev-machine-guard-VERSION-windows_arm64.exe` | Authenticode-signed Windows ARM64 agent | +| `stepsecurity-dev-machine-guard-windows_arm64.exe.bundle` | Sigstore cosign bundle (covers the signed bytes) | +| `stepsecurity-dev-machine-guard-task-VERSION-windows_amd64.exe` | Authenticode-signed Windows 64-bit launcher | +| `stepsecurity-dev-machine-guard-task-windows_amd64.exe.bundle` | Sigstore cosign bundle (covers the signed bytes) | +| `stepsecurity-dev-machine-guard-task-VERSION-windows_arm64.exe` | Authenticode-signed Windows ARM64 launcher | +| `stepsecurity-dev-machine-guard-task-windows_arm64.exe.bundle` | Sigstore cosign bundle (covers the signed bytes) | +| `stepsecurity-dev-machine-guard-VERSION-x64.msi` | Authenticode-signed Windows x64 MSI installer | +| `stepsecurity-dev-machine-guard-VERSION-x64.msi.bundle` | Sigstore cosign bundle for the MSI | +| `stepsecurity-dev-machine-guard-VERSION-arm64.msi` | Authenticode-signed Windows ARM64 MSI installer | +| `stepsecurity-dev-machine-guard-VERSION-arm64.msi.bundle` | Sigstore cosign bundle for the MSI | | `stepsecurity-dev-machine-guard-VERSION-linux_amd64` | Linux 64-bit binary | | `stepsecurity-dev-machine-guard-linux_amd64.bundle` | Sigstore cosign bundle for the Linux amd64 binary | | `stepsecurity-dev-machine-guard-VERSION-linux_arm64` | Linux ARM64 binary | @@ -136,6 +148,54 @@ gh attestation verify "stepsecurity-dev-machine-guard-${VERSION}-darwin_unnotari --repo step-security/dev-machine-guard ``` +### Verify Windows release + +Run on a Windows machine (or any Windows VM) with the Windows 10/11 SDK installed for `signtool`. PowerShell 5.1 is fine. + +```powershell +$VERSION = "1.9.1" + +gh release download "v$VERSION" --repo step-security/dev-machine-guard ` + --pattern "stepsecurity-dev-machine-guard-$VERSION-windows_amd64.exe" ` + --pattern "stepsecurity-dev-machine-guard-windows_amd64.exe.bundle" ` + --pattern "stepsecurity-dev-machine-guard-$VERSION-x64.msi" ` + --pattern "stepsecurity-dev-machine-guard-$VERSION-x64.msi.bundle" + +# Authenticode + RFC3161 timestamp +Get-AuthenticodeSignature ".\stepsecurity-dev-machine-guard-$VERSION-windows_amd64.exe" +Get-AuthenticodeSignature ".\stepsecurity-dev-machine-guard-$VERSION-x64.msi" +# Expected: Status=Valid, SignerCertificate.Subject contains "Step Security, Inc.", +# TimeStamperCertificate.Subject contains "Microsoft". + +# Full chain via signtool (path may vary by Windows SDK version) +$signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin\*\x64\signtool.exe" | + Sort-Object FullName -Descending | Select-Object -First 1 +& $signtool.FullName verify /pa /v ".\stepsecurity-dev-machine-guard-$VERSION-windows_amd64.exe" +& $signtool.FullName verify /pa /v ".\stepsecurity-dev-machine-guard-$VERSION-x64.msi" +``` + +Verify the Sigstore bundle covers the Authenticode-signed bytes (run on any machine with cosign installed): + +```bash +VERSION="1.9.1" + +cosign verify-blob "stepsecurity-dev-machine-guard-${VERSION}-windows_amd64.exe" \ + --bundle "stepsecurity-dev-machine-guard-windows_amd64.exe.bundle" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --certificate-identity-regexp "^https://github.com/step-security/dev-machine-guard/.github/workflows/" + +cosign verify-blob "stepsecurity-dev-machine-guard-${VERSION}-x64.msi" \ + --bundle "stepsecurity-dev-machine-guard-${VERSION}-x64.msi.bundle" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --certificate-identity-regexp "^https://github.com/step-security/dev-machine-guard/.github/workflows/" + +# SLSA build provenance +gh attestation verify "stepsecurity-dev-machine-guard-${VERSION}-windows_amd64.exe" \ + --repo step-security/dev-machine-guard +gh attestation verify "stepsecurity-dev-machine-guard-${VERSION}-x64.msi" \ + --repo step-security/dev-machine-guard +``` + ### Install via package manager (Linux) **Debian / Ubuntu:** @@ -188,9 +248,11 @@ gh attestation verify "stepsecurity-dev-machine-guard-${VERSION}-linux_${ARCH}" ## Immutability Guarantees 1. **Draft → publish flow** — binaries are uploaded to a draft release, notarized manually, then published. Once published, the release is immutable. -2. **Sigstore transparency log** — the unnotarized binary signature is recorded in the public [Rekor](https://rekor.sigstore.dev/) transparency log. +2. **Sigstore transparency log** — every artifact's signature is recorded in the public [Rekor](https://rekor.sigstore.dev/) transparency log. Windows cosign bundles cover the post-Authenticode bytes, so they match what users download. 3. **SLSA build provenance** — attestation links the artifact to the exact workflow run, commit SHA, and build environment. -4. **Duplicate tag check** — the release workflow fails if the tag already exists. +4. **Authenticode + RFC3161 timestamp** — Windows `.exe` and `.msi` signatures from Azure Trusted Signing are timestamped by Microsoft's RFC3161 timestamp server, so they remain verifiable on Windows after the signing certificate expires. +5. **Release environment gate** — the Windows signing job won't run without approval from two reviewers, and only from `main`. +6. **Duplicate tag check** — the release workflow fails if the tag already exists. ---