From fc81df7c59741d3f512e8851f04a1d957837ebce Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 7 Sep 2026 04:32:46 +0000 Subject: [PATCH] Authenticode-sign the Windows stable-diffusion.cpp prebuilts --- .github/actions/sign-windows/action.yml | 150 ++++++++++++++++ .../scripts/assert-windows-bundle-signed.ps1 | 98 +++++++++++ .github/scripts/sign-windows-tree.ps1 | 161 ++++++++++++++++++ .github/workflows/unsloth-sd-prebuilt.yml | 34 ++++ 4 files changed, 443 insertions(+) create mode 100644 .github/actions/sign-windows/action.yml create mode 100644 .github/scripts/assert-windows-bundle-signed.ps1 create mode 100644 .github/scripts/sign-windows-tree.ps1 diff --git a/.github/actions/sign-windows/action.yml b/.github/actions/sign-windows/action.yml new file mode 100644 index 000000000..aebeab987 --- /dev/null +++ b/.github/actions/sign-windows/action.yml @@ -0,0 +1,150 @@ +# SPDX-License-Identifier: MIT +# Copyright 2026-present the Unsloth AI Inc. team. +name: Sign Windows binaries +description: >- + Authenticode-sign every PE in a built Windows tree with Azure Trusted Signing, + so the bundles the Unsloth Studio installer downloads are not blocked by Smart + App Control when they load on a user machine. + +inputs: + path: + description: Directory to sign, searched recursively. + required: true + azure-client-id: + description: AZURE_CLIENT_ID from the release-signing environment. + required: false + default: '' + azure-client-secret: + description: AZURE_CLIENT_SECRET from the release-signing environment. + required: false + default: '' + azure-tenant-id: + description: AZURE_TENANT_ID from the release-signing environment. + required: false + default: '' + azure-account: + description: AZURE_TRUSTED_SIGNING_ACCOUNT_NAME from the release-signing environment. + required: false + default: '' + azure-certificate-profile: + description: AZURE_CERTIFICATE_PROFILE_NAME from the release-signing environment. + required: false + default: '' + endpoint: + description: Azure Trusted Signing endpoint for the account's region. + required: false + default: https://eus.codesigning.azure.net + +outputs: + signed: + description: '"true" when the tree was signed, "false" when signing was skipped on a fork.' + value: ${{ steps.gate.outputs.signed }} + +runs: + using: composite + steps: + # Forks cannot read the signing secrets, so their builds are allowed through + # unsigned rather than failing on something a contributor cannot fix. On the + # canonical repo the same missing secret is a hard error: silently shipping + # an unsigned release is the exact failure this action exists to prevent, + # and a skip that looks like a pass is how the Defender scan in + # unslothai/unsloth#8358 went missing for five releases. + - name: Decide whether signing runs + id: gate + shell: pwsh + env: + AZURE_CLIENT_ID: ${{ inputs.azure-client-id }} + run: | + $ErrorActionPreference = 'Continue' + $haveSecrets = -not [string]::IsNullOrWhiteSpace($env:AZURE_CLIENT_ID) + # By owner rather than by full name: every unslothai repo that + # publishes Windows bundles must fail closed here, and a fork under + # any other owner cannot read the secrets to begin with. + $canonical = ($env:GITHUB_REPOSITORY_OWNER -eq 'unslothai') + if ($haveSecrets) { + Add-Content -Path $env:GITHUB_OUTPUT -Value 'signed=true' + Write-Host 'Azure Trusted Signing credentials present; signing.' + } elseif ($canonical) { + Write-Host "::error::Azure Trusted Signing secrets are missing on $env:GITHUB_REPOSITORY." + Write-Host 'Add AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID,' + Write-Host 'AZURE_TRUSTED_SIGNING_ACCOUNT_NAME and AZURE_CERTIFICATE_PROFILE_NAME' + Write-Host 'to the release-signing environment. Refusing to publish unsigned binaries.' + exit 1 + } else { + Add-Content -Path $env:GITHUB_OUTPUT -Value 'signed=false' + Write-Host "::warning::No signing secrets on $env:GITHUB_REPOSITORY; leaving binaries unsigned." + } + + # Pinned by digest. This binary runs in the step that holds the signing + # credentials, so where it comes from is itself a key-handling question. + - name: Install trusted-signing-cli + if: ${{ steps.gate.outputs.signed == 'true' }} + shell: pwsh + env: + TRUSTED_SIGNING_CLI_URL: https://github.com/Levminer/artifact-signing-cli/releases/download/0.10.0/trusted-signing-cli.exe + TRUSTED_SIGNING_CLI_SHA256: 8c9d750ca582891a7433925dcf302d5d3761fbed757b46c4bf5b417562dccb0e + run: | + $ErrorActionPreference = 'Stop' + $dir = Join-Path $env:RUNNER_TEMP 'trusted-signing-cli' + New-Item -ItemType Directory -Force -Path $dir | Out-Null + $dest = Join-Path $dir 'trusted-signing-cli.exe' + Invoke-WebRequest -Uri $env:TRUSTED_SIGNING_CLI_URL -OutFile $dest -MaximumRetryCount 3 -RetryIntervalSec 5 + $actual = (Get-FileHash $dest -Algorithm SHA256).Hash.ToLower() + $expected = $env:TRUSTED_SIGNING_CLI_SHA256.ToLower() + if ($actual -ne $expected) { + Write-Host "::error::trusted-signing-cli digest mismatch, refusing to sign with an unverified binary. Expected $expected, got $actual." + exit 1 + } + Write-Host "verified trusted-signing-cli sha256=$actual" + Add-Content -Path $env:GITHUB_PATH -Value $dir + + # Fails closed, matching the desktop release workflow. Without this a + # missing or broken binary only surfaces later as a signing error with no + # obvious cause. + - name: Verify trusted-signing-cli + if: ${{ steps.gate.outputs.signed == 'true' }} + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $cli = Get-Command trusted-signing-cli -ErrorAction SilentlyContinue + if (-not $cli) { + Write-Output "::error::trusted-signing-cli is not on PATH. Check the install step above." + exit 1 + } + # sign-windows-tree.ps1 invokes this by bare name, so whatever PATH + # resolves is what signs. Only the digest-verified copy may do that; + # anything else under the same name is unverified by definition. + $verified = [IO.Path]::GetFullPath((Join-Path (Join-Path $env:RUNNER_TEMP 'trusted-signing-cli') 'trusted-signing-cli.exe')) + if ([IO.Path]::GetFullPath($cli.Source) -ne $verified) { + Write-Output "::error::trusted-signing-cli resolved to $($cli.Source), not the verified $verified. Refusing to sign with a binary that was never digest checked." + exit 1 + } + # Two distinct failure modes, neither covering the other: a truncated + # download cannot start at all, which raises under Stop and must be + # caught; a binary that starts and exits non-zero does not raise, so + # $LASTEXITCODE has to be read. + try { + & $cli.Source --version + } catch { + $detail = ($_.Exception.Message -replace '\s+', ' ').Trim() + Write-Output "::error::trusted-signing-cli could not be started. Re-run to fetch it again; if it persists the pinned asset is bad. Underlying error: $detail" + exit 1 + } + if ($LASTEXITCODE -ne 0) { + Write-Output "::error::trusted-signing-cli is on PATH but exited $LASTEXITCODE." + exit 1 + } + + - name: Sign every PE in the build tree + if: ${{ steps.gate.outputs.signed == 'true' }} + shell: pwsh + env: + AZURE_CLIENT_ID: ${{ inputs.azure-client-id }} + AZURE_CLIENT_SECRET: ${{ inputs.azure-client-secret }} + AZURE_TENANT_ID: ${{ inputs.azure-tenant-id }} + AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ inputs.azure-account }} + AZURE_CERTIFICATE_PROFILE_NAME: ${{ inputs.azure-certificate-profile }} + run: | + $ErrorActionPreference = 'Continue' + & "$env:GITHUB_ACTION_PATH/../../scripts/sign-windows-tree.ps1" -Path "${{ inputs.path }}" -Endpoint "${{ inputs.endpoint }}" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.github/scripts/assert-windows-bundle-signed.ps1 b/.github/scripts/assert-windows-bundle-signed.ps1 new file mode 100644 index 000000000..53a9bd144 --- /dev/null +++ b/.github/scripts/assert-windows-bundle-signed.ps1 @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: MIT +# Copyright 2026-present the Unsloth AI Inc. team. +# Fail if any PE inside a packaged Windows bundle is unsigned. +# +# This is the release gate. sign-windows-tree.ps1 signs the build output, but the +# packaging steps copy extra files in afterwards (the OpenMP runtime out of the +# Visual Studio redist tree, and the ROCm runtime DLLs out of the TheRock dist), +# so the only place that can prove what actually ships is the finished zip. +# +# Runs against zips rather than directories on purpose: an unsigned file that +# gets added between signing and packaging is invisible to any earlier check. + +param( + # Bundle zips to verify; every PE inside each is checked. + [Parameter(Mandatory = $true)][string[]] $Path, + # 7-Zip, preinstalled on the GitHub Windows images. + [string] $SevenZip = '7z', + # Known-unsigned leaf names to accept. Keep this empty. Anything listed here + # is a file we ship that Smart App Control can still refuse to load. + [string[]] $Allow = @() +) + +$ErrorActionPreference = 'Continue' +$peExtensions = @('.exe', '.dll', '.pyd', '.sys', '.ocx', '.cpl', '.scr') + +$unsigned = @() +$checked = 0 + +foreach ($bundle in $Path) { + if (-not (Test-Path -LiteralPath $bundle -PathType Leaf)) { + Write-Host "::error::bundle not found: $bundle" + exit 1 + } + $name = Split-Path $bundle -Leaf + Write-Host '' + Write-Host "=== $name ===" + + $root = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [System.IO.Path]::GetTempPath() } + $dest = Join-Path $root ('sigcheck-' + [System.IO.Path]::GetFileNameWithoutExtension($name)) + Remove-Item -LiteralPath $dest -Recurse -Force -ErrorAction SilentlyContinue + & $SevenZip x -y "-o$dest" $bundle | Out-Null + # 7-Zip leaves a partial tree behind on error, so a created directory proves + # nothing about whether the archive was fully extracted. + if ($LASTEXITCODE -ne 0) { + Write-Host "::error::7-Zip exited $LASTEXITCODE unpacking $name; contents not verified" + exit 1 + } + if (-not (Test-Path -LiteralPath $dest)) { + Write-Host "::error::could not unpack $name; cannot verify its contents" + exit 1 + } + + $inner = @( + Get-ChildItem -LiteralPath $dest -Recurse -File | + Where-Object { $peExtensions -contains $_.Extension.ToLowerInvariant() } + ) + # No hits means the archive did not contain what we think it does, not that + # the payload is clean. + if ($inner.Count -eq 0) { + Write-Host "::error::no executable payload found inside $name; contents not verified" + exit 1 + } + + foreach ($f in ($inner | Sort-Object Name)) { + $checked++ + $s = Get-AuthenticodeSignature -LiteralPath $f.FullName + if ($s.Status -eq 'Valid') { + Write-Host (' signed {0}' -f $f.Name) + } elseif ($Allow -contains $f.Name) { + Write-Host (' ALLOWED {0} ({1}) - explicitly accepted as unsigned' -f $f.Name, $s.Status) + } else { + # UnknownError covers both "no signature at all" and "chain did not + # build"; StatusMessage is what distinguishes them. + Write-Host (' UNSIGNED {0} ({1}) {2}' -f $f.Name, $s.Status, $s.StatusMessage) + $unsigned += [pscustomobject]@{ Bundle = $name; File = $f.Name; Status = [string]$s.Status } + } + } + Remove-Item -LiteralPath $dest -Recurse -Force -ErrorAction SilentlyContinue +} + +Write-Host '' +Write-Host "checked $checked file(s) across $($Path.Count) bundle(s)" + +if ($unsigned.Count -eq 0) { + Write-Host 'Every PE in every bundle is validly signed.' + exit 0 +} + +Write-Host '' +Write-Host '================ UNSIGNED FILES ================' +$unsigned | Format-Table Bundle, File, Status -AutoSize | Out-String | Write-Host +foreach ($u in $unsigned) { + Write-Host "::error file=$($u.File)::$($u.File) in $($u.Bundle) is $($u.Status) and needs signing" +} +Write-Host '' +Write-Host 'These ship to user machines and are loaded by llama-server.' +Write-Host 'Smart App Control blocks unknown unsigned binaries as they load.' +exit 1 diff --git a/.github/scripts/sign-windows-tree.ps1 b/.github/scripts/sign-windows-tree.ps1 new file mode 100644 index 000000000..2de7fef84 --- /dev/null +++ b/.github/scripts/sign-windows-tree.ps1 @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: MIT +# Copyright 2026-present the Unsloth AI Inc. team. +# Authenticode-sign every PE in a built Windows tree with Azure Trusted Signing. +# +# The bundles produced here are downloaded and executed on user machines by the +# Unsloth Studio installer, which fetches them after its own signed installer has +# already run. Nothing else signs them, so an unsigned file here reaches the user +# unsigned. Windows Smart App Control evaluates every binary as it loads and +# blocks unknown unsigned code, reporting it as a "Bad Image" dialog with status +# 0xc0e90002 naming whichever dependent DLL it refused, so signing only the +# launcher executables is not enough: every DLL in the tree has to be signed. +# +# Signing is batched. trusted-signing-cli takes any number of trailing paths and +# authenticates to Azure once per invocation, so a bundle of ~50 files costs one +# round trip instead of fifty. + +param( + # Directory to sign, searched recursively. + [Parameter(Mandatory = $true)][string] $Path, + # Azure Trusted Signing endpoint, matched to the account's region. + [string] $Endpoint = 'https://eus.codesigning.azure.net', + # Signature description shown in the Windows UAC/properties dialog. + [string] $Description = 'Unsloth', + # Files per trusted-signing-cli invocation. Batching amortizes Azure auth; + # an unbounded batch would risk the Windows command line length limit. + [int] $BatchSize = 40, + [int] $MaxAttempts = 3 +) + +$ErrorActionPreference = 'Continue' + +# Extensions Smart App Control and WDAC evaluate at load time. .pyd is included +# because the ROCm and CUDA bundles can carry Python extension modules, which are +# ordinary PEs under a different suffix. +$peExtensions = @('.exe', '.dll', '.pyd', '.sys', '.ocx', '.cpl', '.scr') + +if (-not (Test-Path -LiteralPath $Path)) { + Write-Host "::error::sign-windows-tree: path not found: $Path" + exit 1 +} + +$files = @( + Get-ChildItem -LiteralPath $Path -Recurse -File | + Where-Object { $peExtensions -contains $_.Extension.ToLowerInvariant() } | + Sort-Object FullName +) + +# An empty tree means the build step silently produced nothing. Signing zero +# files and reporting success would let that reach the release. +if ($files.Count -eq 0) { + Write-Host "::error::sign-windows-tree: no PE files found under $Path; nothing was built" + exit 1 +} + +# Leave an existing valid signature alone. Signing replaces it, and some of +# what we bundle arrives already signed by its vendor: the OpenMP runtime is +# signed by Microsoft, and re-signing it would strip that and substitute ours +# for no gain. Files that are unsigned, or whose chain does not build, are ours +# to sign. Everything is re-verified at the end either way. +$alreadySigned = @() +$toSign = @() +foreach ($f in $files) { + if ((Get-AuthenticodeSignature -LiteralPath $f.FullName).Status -eq 'Valid') { + $alreadySigned += $f + } else { + $toSign += $f + } +} + +if ($alreadySigned.Count -gt 0) { + Write-Host "leaving $($alreadySigned.Count) already-signed file(s) untouched:" + foreach ($f in $alreadySigned) { Write-Host " $($f.Name)" } +} + +if ($toSign.Count -eq 0) { + Write-Host "all $($files.Count) PE file(s) under $Path are already validly signed" + exit 0 +} + +$files = $toSign +Write-Host "signing $($files.Count) PE file(s) under $Path" + +# Retried only for Azure auth flakiness. A signing rejection is a real failure +# and repeating it just burns quota. +$retryPatterns = @( + 'No subscriptions found', + 'login via azure cli', + 'az\.cmd.*exited with code 1', + 'Failed to acquire token', + 'temporarily unavailable', + 'Response status code does not indicate success: 429', + 'Response status code does not indicate success: 50[0-9]' +) + +$batches = [System.Collections.Generic.List[object]]::new() +for ($i = 0; $i -lt $files.Count; $i += $BatchSize) { + $end = [Math]::Min($i + $BatchSize, $files.Count) - 1 + $batches.Add(@($files[$i..$end])) +} + +$batchNumber = 0 +foreach ($batch in $batches) { + $batchNumber++ + $paths = @($batch | ForEach-Object { $_.FullName }) + Write-Host '' + Write-Host "=== batch $batchNumber/$($batches.Count): $($paths.Count) file(s) ===" + + $signed = $false + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + $cliArgs = @('-e', $Endpoint, '-d', $Description) + $paths + $output = & trusted-signing-cli @cliArgs 2>&1 + $exitCode = $LASTEXITCODE + if ($null -eq $exitCode) { $exitCode = 1 } + $text = $output | Out-String + foreach ($line in $output) { Write-Output $line } + + if ($exitCode -eq 0) { $signed = $true; break } + + $isRetryable = $false + foreach ($pattern in $retryPatterns) { + if ($text -match $pattern) { $isRetryable = $true; break } + } + if (-not $isRetryable -or $attempt -eq $MaxAttempts) { + Write-Host "::error::trusted-signing-cli exited $exitCode on batch $batchNumber" + exit $exitCode + } + Write-Warning "trusted-signing-cli hit a transient Azure error; retrying batch $batchNumber." + Start-Sleep -Seconds (5 * $attempt) + } + + if (-not $signed) { + Write-Host "::error::batch $batchNumber was not signed" + exit 1 + } +} + +Write-Host '' +Write-Host "signed $($files.Count) file(s); verifying" + +# Verify here as well as in the release gate. Catching an unsigned file in the +# job that produced it names the build leg directly, where the gate downstream +# can only say which bundle was wrong. +$bad = @() +foreach ($f in $files) { + $sig = Get-AuthenticodeSignature -LiteralPath $f.FullName + if ($sig.Status -ne 'Valid') { + $bad += [pscustomobject]@{ File = $f.Name; Status = [string]$sig.Status; Message = $sig.StatusMessage } + } +} + +if ($bad.Count -gt 0) { + Write-Host '' + $bad | Format-Table File, Status, Message -AutoSize | Out-String | Write-Host + foreach ($b in $bad) { + Write-Host "::error file=$($b.File)::$($b.File) is $($b.Status) after signing" + } + exit 1 +} + +Write-Host "all $($files.Count) file(s) report a valid Authenticode signature" +exit 0 diff --git a/.github/workflows/unsloth-sd-prebuilt.yml b/.github/workflows/unsloth-sd-prebuilt.yml index 8d623d526..291ee0f7a 100644 --- a/.github/workflows/unsloth-sd-prebuilt.yml +++ b/.github/workflows/unsloth-sd-prebuilt.yml @@ -520,6 +520,9 @@ jobs: needs: resolve if: ${{ needs.resolve.outputs.exists != 'true' || github.event_name == 'workflow_dispatch' }} runs-on: windows-2022 + # Gate on the environment, matching unslothai/unsloth's release-desktop.yml: + # the job that can read the signing credentials is named explicitly. + environment: release-signing steps: - name: Checkout mirror (tooling) uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -561,6 +564,21 @@ jobs: -DGGML_NATIVE=OFF cmake --build build --config Release -j 3 --target sd-cli sd-server + # Before packaging, so every PE the bundle carries is signed. Windows + # Smart App Control evaluates each binary as it loads and refuses an + # unknown unsigned one with status 0xc0e90002, which the user sees as a + # "Bad Image" dialog naming the file. sd-cli.exe and sd-server.exe ship + # unsigned today. + - name: Sign Windows binaries + uses: ./tooling/.github/actions/sign-windows + with: + path: src/build/bin + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-account: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} + azure-certificate-profile: ${{ secrets.AZURE_CERTIFICATE_PROFILE_NAME }} + - name: Package bundle shell: pwsh env: @@ -573,6 +591,22 @@ jobs: LICENSE_FILE: ${{ github.workspace }}/src/LICENSE run: python tooling/scripts/unsloth/package_bundle.py + # The gate, run on the finished zip rather than the build tree: packaging + # copies files in after signing, so the zip is the only thing that proves + # what ships. + - name: Verify every PE in the bundle is signed + shell: pwsh + run: | + $ErrorActionPreference = 'Continue' + $zips = @(Get-ChildItem dist -Filter *.zip -ErrorAction SilentlyContinue | + ForEach-Object { $_.FullName }) + if ($zips.Count -eq 0) { + Write-Host '::error::no bundle in dist/; packaging produced nothing to verify' + exit 1 + } + & tooling/.github/scripts/assert-windows-bundle-signed.ps1 -Path $zips + exit $LASTEXITCODE + - name: Upload bundle uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: