|
| 1 | +name: Verify Release |
| 2 | +on: |
| 3 | + workflow_dispatch: |
| 4 | + inputs: |
| 5 | + tag: |
| 6 | + description: 'Release tag to verify, e.g. v1.11.6 (draft or published).' |
| 7 | + required: true |
| 8 | + type: string |
| 9 | + repository: |
| 10 | + description: >- |
| 11 | + Repo to verify, as owner/name. Leave blank to use the repo this |
| 12 | + workflow runs in. Set it to step-security/dev-machine-guard to verify |
| 13 | + an upstream PUBLISHED release (handy for confirming this workflow |
| 14 | + works before opening a PR — draft releases in another repo are not |
| 15 | + visible to this repo's token, so only published upstream releases work). |
| 16 | + required: false |
| 17 | + type: string |
| 18 | + default: '' |
| 19 | + |
| 20 | +permissions: {} |
| 21 | + |
| 22 | +env: |
| 23 | + # GoReleaser sets tag_name on the draft, so gh resolves drafts by this tag. |
| 24 | + TAG: ${{ inputs.tag }} |
| 25 | + # Empty input falls back to the current repo. An explicit owner/name (e.g. |
| 26 | + # step-security/dev-machine-guard) targets that repo's PUBLISHED releases — |
| 27 | + # public release assets are readable cross-repo with this repo's token. |
| 28 | + REPO: ${{ inputs.repository || github.repository }} |
| 29 | + |
| 30 | +jobs: |
| 31 | + # --------------------------------------------------------------------------- |
| 32 | + # Requirement 3: every relevant binary artifact has a valid, correct signed |
| 33 | + # checksum. Runs on Linux because ssh-keygen -Y verify (OpenSSH) is the same |
| 34 | + # verifier loader.sh uses on customer machines. |
| 35 | + # --------------------------------------------------------------------------- |
| 36 | + verify-signed-checksums: |
| 37 | + name: Verify signed checksums |
| 38 | + runs-on: ubuntu-latest |
| 39 | + permissions: |
| 40 | + # contents: write (not read) is required so the GITHUB_TOKEN can see DRAFT |
| 41 | + # releases — GitHub only exposes drafts to tokens with push access. The job |
| 42 | + # only ever downloads assets; it never modifies the repo or the release. |
| 43 | + contents: write |
| 44 | + steps: |
| 45 | + - name: Harden the runner (Audit all outbound calls) |
| 46 | + # Intentionally left unpinned — pin to a commit SHA after verifying. |
| 47 | + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 |
| 48 | + with: |
| 49 | + egress-policy: audit |
| 50 | + |
| 51 | + - name: Download release assets |
| 52 | + env: |
| 53 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 54 | + run: | |
| 55 | + set -euo pipefail |
| 56 | + mkdir -p assets |
| 57 | + gh release download "$TAG" -R "$REPO" -D assets |
| 58 | + echo "Downloaded assets:" |
| 59 | + ls -la assets |
| 60 | +
|
| 61 | + - name: Verify signed checksums (Ed25519 / ssh-keygen -Y verify) |
| 62 | + run: | |
| 63 | + set -euo pipefail |
| 64 | + VERSION="${TAG#v}" |
| 65 | +
|
| 66 | + # --- Verification scheme, kept in sync with loader.sh --- |
| 67 | + PUBLIC_KEY_SSH="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILN+WG4lOH/x6MysYOf1oY0PKXLLu9d3ZvQDcvq5Cboi releases@stepsecurity.io" |
| 68 | + SIGNATURE_NAMESPACE="stepsecurity-mdm-checksum" |
| 69 | + SIGNATURE_IDENTITY="releases@stepsecurity.io" |
| 70 | +
|
| 71 | + ALLOWED_SIGNERS="$(mktemp)" |
| 72 | + # The namespaces value MUST be double-quoted or ssh-keygen's option |
| 73 | + # parser rejects it ("bad options: missing start quote"). |
| 74 | + printf '%s namespaces="%s" %s\n' \ |
| 75 | + "$SIGNATURE_IDENTITY" "$SIGNATURE_NAMESPACE" "$PUBLIC_KEY_SSH" \ |
| 76 | + > "$ALLOWED_SIGNERS" |
| 77 | +
|
| 78 | + # Distributable binaries that MUST ship a signed checksum. These are |
| 79 | + # the artifacts the loader / installers download and run. The cosign |
| 80 | + # .bundle-only artifacts (deb, rpm, darwin_unnotarized) are deliberately |
| 81 | + # excluded — they are not served as signed-checksum downloads. |
| 82 | + ARTIFACTS=( |
| 83 | + "stepsecurity-dev-machine-guard-${VERSION}-darwin" |
| 84 | + "stepsecurity-dev-machine-guard-${VERSION}-linux_amd64" |
| 85 | + "stepsecurity-dev-machine-guard-${VERSION}-linux_arm64" |
| 86 | + "stepsecurity-dev-machine-guard-${VERSION}-windows_amd64.exe" |
| 87 | + "stepsecurity-dev-machine-guard-${VERSION}-windows_arm64.exe" |
| 88 | + "stepsecurity-dev-machine-guard-task-${VERSION}-windows_amd64.exe" |
| 89 | + "stepsecurity-dev-machine-guard-task-${VERSION}-windows_arm64.exe" |
| 90 | + "stepsecurity-dev-machine-guard-${VERSION}-x64.msi" |
| 91 | + "stepsecurity-dev-machine-guard-${VERSION}-arm64.msi" |
| 92 | + ) |
| 93 | +
|
| 94 | + fail=0 |
| 95 | + { |
| 96 | + echo "## Signed checksum verification" |
| 97 | + echo "" |
| 98 | + echo "| Artifact | Signed checksum | Result |" |
| 99 | + echo "| --- | --- | --- |" |
| 100 | + } >> "$GITHUB_STEP_SUMMARY" |
| 101 | +
|
| 102 | + for art in "${ARTIFACTS[@]}"; do |
| 103 | + bin="assets/${art}" |
| 104 | + sig="assets/${art}.sha256.sig" |
| 105 | + status="OK" |
| 106 | +
|
| 107 | + if [ ! -f "$bin" ]; then |
| 108 | + echo "::error::Missing artifact: ${art}" |
| 109 | + echo "| \`${art}\` | n/a | ❌ artifact missing |" >> "$GITHUB_STEP_SUMMARY" |
| 110 | + fail=1 |
| 111 | + continue |
| 112 | + fi |
| 113 | + if [ ! -f "$sig" ]; then |
| 114 | + echo "::error::Missing signed checksum: ${art}.sha256.sig" |
| 115 | + echo "| \`${art}\` | ❌ missing | ❌ no .sha256.sig |" >> "$GITHUB_STEP_SUMMARY" |
| 116 | + fail=1 |
| 117 | + continue |
| 118 | + fi |
| 119 | +
|
| 120 | + # Actual SHA-256 of the artifact bytes. |
| 121 | + hex="$(sha256sum "$bin" | awk '{print $1}')" |
| 122 | +
|
| 123 | + # The .sha256.sig is the base64-wrapped armored SSH signature. Decode |
| 124 | + # it back to the multi-line -----BEGIN SSH SIGNATURE----- blob. |
| 125 | + decoded="$(mktemp)" |
| 126 | + if ! base64 -d < "$sig" > "$decoded" 2>/dev/null; then |
| 127 | + echo "::error::Failed to base64-decode ${art}.sha256.sig" |
| 128 | + echo "| \`${art}\` | ❌ undecodable | ❌ bad base64 |" >> "$GITHUB_STEP_SUMMARY" |
| 129 | + fail=1 |
| 130 | + continue |
| 131 | + fi |
| 132 | +
|
| 133 | + # The signed message is the BARE hex SHA-256 with NO trailing newline. |
| 134 | + # Verifying against the freshly-computed hex proves both that the |
| 135 | + # signature is valid AND that it covers this artifact's real checksum. |
| 136 | + if printf '%s' "$hex" | ssh-keygen -Y verify \ |
| 137 | + -f "$ALLOWED_SIGNERS" \ |
| 138 | + -I "$SIGNATURE_IDENTITY" \ |
| 139 | + -n "$SIGNATURE_NAMESPACE" \ |
| 140 | + -s "$decoded" >/dev/null 2>&1; then |
| 141 | + echo "OK: ${art} (sha256=${hex}) — signature verified" |
| 142 | + echo "| \`${art}\` | \`${hex:0:16}…\` | ✅ verified |" >> "$GITHUB_STEP_SUMMARY" |
| 143 | + else |
| 144 | + echo "::error::Signed checksum verification FAILED for ${art}" |
| 145 | + echo "| \`${art}\` | \`${hex:0:16}…\` | ❌ signature invalid |" >> "$GITHUB_STEP_SUMMARY" |
| 146 | + fail=1 |
| 147 | + fi |
| 148 | + done |
| 149 | +
|
| 150 | + if [ "$fail" -ne 0 ]; then |
| 151 | + echo "::error::One or more signed checksums are missing or invalid." |
| 152 | + exit 1 |
| 153 | + fi |
| 154 | + echo "All relevant artifacts have valid signed checksums." |
| 155 | +
|
| 156 | + # NOTE: we deliberately do NOT cross-check the GoReleaser-generated |
| 157 | + # checksums.txt. That file records the PRE-signing Windows hashes — |
| 158 | + # the release pipeline Authenticode-signs the .exe/.msi files and |
| 159 | + # re-uploads them with --clobber afterwards, so the published bytes no |
| 160 | + # longer match checksums.txt for any Windows artifact. The signed |
| 161 | + # .sha256.sig files verified above are computed on the final served |
| 162 | + # bytes, which is the source of truth this gate enforces. |
| 163 | +
|
| 164 | + # --------------------------------------------------------------------------- |
| 165 | + # Requirement 1: Windows .exe + .msi are Authenticode-signed. Mirrors the |
| 166 | + # verification the Release workflow performs after signing. |
| 167 | + # --------------------------------------------------------------------------- |
| 168 | + verify-windows-signatures: |
| 169 | + name: Verify Windows signatures |
| 170 | + runs-on: windows-latest |
| 171 | + permissions: |
| 172 | + contents: write # see drafts; see note in verify-signed-checksums |
| 173 | + steps: |
| 174 | + - name: Harden the runner (Audit all outbound calls) |
| 175 | + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 |
| 176 | + with: |
| 177 | + egress-policy: audit |
| 178 | + |
| 179 | + - name: Download Windows assets |
| 180 | + env: |
| 181 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 182 | + shell: bash |
| 183 | + run: | |
| 184 | + set -euo pipefail |
| 185 | + mkdir -p assets |
| 186 | + gh release download "$TAG" -R "$REPO" -D assets \ |
| 187 | + -p "*-windows_amd64.exe" \ |
| 188 | + -p "*-windows_arm64.exe" \ |
| 189 | + -p "*-x64.msi" \ |
| 190 | + -p "*-arm64.msi" |
| 191 | + ls -la assets |
| 192 | +
|
| 193 | + - name: Verify Authenticode signatures |
| 194 | + shell: pwsh |
| 195 | + run: | |
| 196 | + $ErrorActionPreference = 'Stop' |
| 197 | + $version = "${{ inputs.tag }}".TrimStart('v') |
| 198 | + $files = @( |
| 199 | + "stepsecurity-dev-machine-guard-$version-windows_amd64.exe", |
| 200 | + "stepsecurity-dev-machine-guard-$version-windows_arm64.exe", |
| 201 | + "stepsecurity-dev-machine-guard-task-$version-windows_amd64.exe", |
| 202 | + "stepsecurity-dev-machine-guard-task-$version-windows_arm64.exe", |
| 203 | + "stepsecurity-dev-machine-guard-$version-x64.msi", |
| 204 | + "stepsecurity-dev-machine-guard-$version-arm64.msi" |
| 205 | + ) |
| 206 | +
|
| 207 | + "## Windows Authenticode verification`n" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append |
| 208 | + "| Artifact | Status | Signer | Timestamp |" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append |
| 209 | + "| --- | --- | --- | --- |" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append |
| 210 | +
|
| 211 | + $failed = $false |
| 212 | + foreach ($name in $files) { |
| 213 | + $f = Join-Path "assets" $name |
| 214 | + if (-not (Test-Path $f)) { |
| 215 | + Write-Host "::error::Missing Windows artifact: $name" |
| 216 | + "| ``$name`` | ❌ missing | - | - |" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append |
| 217 | + $failed = $true |
| 218 | + continue |
| 219 | + } |
| 220 | +
|
| 221 | + $sig = Get-AuthenticodeSignature $f |
| 222 | + $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '<none>' } |
| 223 | + $hasTs = [bool]$sig.TimeStamperCertificate |
| 224 | + Write-Host "$name : Status=$($sig.Status), Signer=$subject, Timestamped=$hasTs" |
| 225 | +
|
| 226 | + $ok = $true |
| 227 | + if ($sig.Status -ne 'Valid') { |
| 228 | + Write-Host "::error::Signature status is $($sig.Status) (expected Valid) for $name" |
| 229 | + $ok = $false |
| 230 | + } |
| 231 | + if (-not $hasTs) { |
| 232 | + Write-Host "::error::No RFC3161 timestamp on $name" |
| 233 | + $ok = $false |
| 234 | + } |
| 235 | +
|
| 236 | + $mark = if ($ok) { '✅ valid' } else { "❌ $($sig.Status)" } |
| 237 | + $tsMark = if ($hasTs) { '✅' } else { '❌' } |
| 238 | + "| ``$name`` | $mark | $subject | $tsMark |" | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append |
| 239 | + if (-not $ok) { $failed = $true } |
| 240 | + } |
| 241 | +
|
| 242 | + if ($failed) { |
| 243 | + Write-Host "::error::One or more Windows artifacts are not properly Authenticode-signed." |
| 244 | + exit 1 |
| 245 | + } |
| 246 | + Write-Host "All Windows artifacts are Authenticode-signed and timestamped." |
| 247 | +
|
| 248 | + # --------------------------------------------------------------------------- |
| 249 | + # Requirement 2: the macOS darwin binary is signed (Developer ID + hardened |
| 250 | + # runtime) and notarized. Runs on macOS so codesign/spctl are available; spctl |
| 251 | + # performs the online Gatekeeper/notarization check. |
| 252 | + # --------------------------------------------------------------------------- |
| 253 | + verify-macos-notarization: |
| 254 | + name: Verify macOS notarization |
| 255 | + runs-on: macos-latest |
| 256 | + permissions: |
| 257 | + contents: write # see drafts; see note in verify-signed-checksums |
| 258 | + steps: |
| 259 | + - name: Harden the runner (Audit all outbound calls) |
| 260 | + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 |
| 261 | + with: |
| 262 | + egress-policy: audit |
| 263 | + |
| 264 | + - name: Download macOS binary |
| 265 | + env: |
| 266 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 267 | + run: | |
| 268 | + set -euo pipefail |
| 269 | + VERSION="${TAG#v}" |
| 270 | + mkdir -p assets |
| 271 | + # Match only the notarized darwin binary, not *-darwin_unnotarized. |
| 272 | + gh release download "$TAG" -R "$REPO" -D assets \ |
| 273 | + -p "stepsecurity-dev-machine-guard-${VERSION}-darwin" |
| 274 | + ls -la assets |
| 275 | +
|
| 276 | + - name: Verify codesign + notarization |
| 277 | + run: | |
| 278 | + set -euo pipefail |
| 279 | + VERSION="${TAG#v}" |
| 280 | + EXPECTED_AUTHORITY="Developer ID Application: Step Security, Inc. (D63S9HLM4L)" |
| 281 | + BIN="assets/stepsecurity-dev-machine-guard-${VERSION}-darwin" |
| 282 | +
|
| 283 | + if [ ! -f "$BIN" ]; then |
| 284 | + echo "::error::macOS darwin binary not found: $(basename "$BIN")" |
| 285 | + exit 1 |
| 286 | + fi |
| 287 | +
|
| 288 | + fail=0 |
| 289 | +
|
| 290 | + # 1. Valid code signature. |
| 291 | + echo "== codesign --verify --strict ==" |
| 292 | + if codesign --verify --strict --verbose=2 "$BIN" 2>&1; then |
| 293 | + echo "codesign verification passed." |
| 294 | + else |
| 295 | + echo "::error::codesign --verify failed for $(basename "$BIN")" |
| 296 | + fail=1 |
| 297 | + fi |
| 298 | +
|
| 299 | + # 2. Signed by the Step Security Developer ID, with the hardened runtime. |
| 300 | + echo "== codesign -dvvv ==" |
| 301 | + info="$(codesign -dvvv "$BIN" 2>&1 || true)" |
| 302 | + echo "$info" | grep -Ei 'Authority|TeamIdentifier|flags|Timestamp' || true |
| 303 | + if echo "$info" | grep -qF "$EXPECTED_AUTHORITY"; then |
| 304 | + echo "Developer ID authority present." |
| 305 | + else |
| 306 | + echo "::error::Expected signing authority not found: ${EXPECTED_AUTHORITY}" |
| 307 | + fail=1 |
| 308 | + fi |
| 309 | + if echo "$info" | grep -Eq 'flags=.*runtime'; then |
| 310 | + echo "Hardened runtime enabled." |
| 311 | + else |
| 312 | + echo "::error::Hardened runtime flag (runtime) not set on $(basename "$BIN")" |
| 313 | + fail=1 |
| 314 | + fi |
| 315 | +
|
| 316 | + # 3. Notarization — the online Gatekeeper assessment. A bare Mach-O |
| 317 | + # binary cannot be stapled, so spctl -t install is the authoritative |
| 318 | + # check (this is exactly what the manual notarization step runs). |
| 319 | + echo "== spctl -a -vvv -t install ==" |
| 320 | + assess="$(spctl -a -vvv -t install "$BIN" 2>&1 || true)" |
| 321 | + echo "$assess" |
| 322 | + if echo "$assess" | grep -q 'accepted' && echo "$assess" | grep -q 'source=Notarized Developer ID'; then |
| 323 | + echo "Binary is notarized (Gatekeeper accepted, Notarized Developer ID)." |
| 324 | + else |
| 325 | + echo "::error::macOS binary is NOT notarized (spctl did not return 'accepted / Notarized Developer ID')." |
| 326 | + fail=1 |
| 327 | + fi |
| 328 | +
|
| 329 | + { |
| 330 | + echo "## macOS notarization verification" |
| 331 | + echo "" |
| 332 | + if [ "$fail" -eq 0 ]; then |
| 333 | + echo "✅ \`$(basename "$BIN")\` is codesigned (Developer ID + hardened runtime) and notarized." |
| 334 | + else |
| 335 | + echo "❌ \`$(basename "$BIN")\` failed one or more macOS signing/notarization checks." |
| 336 | + fi |
| 337 | + } >> "$GITHUB_STEP_SUMMARY" |
| 338 | +
|
| 339 | + if [ "$fail" -ne 0 ]; then |
| 340 | + exit 1 |
| 341 | + fi |
| 342 | + echo "macOS binary is signed and notarized." |
| 343 | +
|
| 344 | + # --------------------------------------------------------------------------- |
| 345 | + # Single pass/fail gate. Fails the run if ANY requirement job did not succeed, |
| 346 | + # giving one clear status to gate publishing the release on. |
| 347 | + # --------------------------------------------------------------------------- |
| 348 | + gate: |
| 349 | + name: Release verification gate |
| 350 | + needs: [verify-signed-checksums, verify-windows-signatures, verify-macos-notarization] |
| 351 | + if: always() |
| 352 | + runs-on: ubuntu-latest |
| 353 | + permissions: {} |
| 354 | + steps: |
| 355 | + - name: Harden the runner (Audit all outbound calls) |
| 356 | + uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3 |
| 357 | + with: |
| 358 | + egress-policy: audit |
| 359 | + |
| 360 | + - name: Summarize and gate |
| 361 | + run: | |
| 362 | + checksums="${{ needs.verify-signed-checksums.result }}" |
| 363 | + windows="${{ needs.verify-windows-signatures.result }}" |
| 364 | + macos="${{ needs.verify-macos-notarization.result }}" |
| 365 | +
|
| 366 | + { |
| 367 | + echo "## Release verification gate — \`${TAG}\`" |
| 368 | + echo "" |
| 369 | + echo "| Requirement | Result |" |
| 370 | + echo "| --- | --- |" |
| 371 | + echo "| Signed checksums (all relevant artifacts) | ${checksums} |" |
| 372 | + echo "| Windows Authenticode signatures | ${windows} |" |
| 373 | + echo "| macOS notarization | ${macos} |" |
| 374 | + } >> "$GITHUB_STEP_SUMMARY" |
| 375 | +
|
| 376 | + if [ "$checksums" = "success" ] && [ "$windows" = "success" ] && [ "$macos" = "success" ]; then |
| 377 | + echo "✅ Release ${TAG} meets all requirements — safe to publish." >> "$GITHUB_STEP_SUMMARY" |
| 378 | + echo "Release ${TAG} meets all requirements." |
| 379 | + else |
| 380 | + echo "❌ Release ${TAG} is NOT ready — do not publish." >> "$GITHUB_STEP_SUMMARY" |
| 381 | + echo "::error::Release ${TAG} failed verification (checksums=${checksums}, windows=${windows}, macos=${macos})." |
| 382 | + exit 1 |
| 383 | + fi |
0 commit comments