From 4c74ab393bc8598a02dc6ea378218a25e36d8452 Mon Sep 17 00:00:00 2001 From: SoftFever Date: Mon, 19 Oct 2026 13:07:53 +0800 Subject: [PATCH] Sign the uninstaller in Windows release installers --- .github/workflows/build_orca.yml | 50 +++++++++++++++---- CMakeLists.txt | 15 ++++-- scripts/restore-from-signpath.ps1 | 12 +++-- scripts/stage-for-signpath.ps1 | 27 +++++----- signpath/README.md | 10 ++-- signpath/windows-binaries-v1.xml | 1 + .../platform/msw/uninstaller-signing.nsh | 19 +++++++ 7 files changed, 97 insertions(+), 37 deletions(-) create mode 100644 src/dev-utils/platform/msw/uninstaller-signing.nsh diff --git a/.github/workflows/build_orca.yml b/.github/workflows/build_orca.yml index 93f77ad539..4a1496e04e 100644 --- a/.github/workflows/build_orca.yml +++ b/.github/workflows/build_orca.yml @@ -508,16 +508,30 @@ jobs: retention-days: 5 if-no-files-found: error - # Signs OrcaSlicer's own binaries and every bundled executable, DLL and Python - # extension module that ships without a signature; files their vendor signed, such - # as the Microsoft runtime, keep that signature. See signpath/README.md. + # makensis generates the uninstaller while it compiles the installer, so a first + # installer build exports it to be signed with the binaries, and "Create installer + # Win" embeds the signed copy. See src/dev-utils/platform/msw/uninstaller-signing.nsh. + - name: Export the Windows uninstaller for SignPath + if: env.SIGN_WINDOWS == 'true' + working-directory: ${{ github.workspace }}/${{ env.BUILD_DIR }} + shell: pwsh + run: | + $bundle = "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer".Replace('\', '/') + # Forward slashes: cpack drops the backslashes from makensis arguments. + $uninstaller = "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/Uninstall.exe".Replace('\', '/') + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $uninstaller) | Out-Null + cpack -G NSIS -D CPACK_INSTALL_CMAKE_PROJECTS= -D "CPACK_INSTALLED_DIRECTORIES=$bundle;." -D "CPACK_NSIS_EXECUTABLE_PRE_ARGUMENTS=/DORCA_EXPORT_UNINSTALLER=$uninstaller" + + # Signs OrcaSlicer's own binaries, the uninstaller and every bundled executable, DLL + # and Python extension module that ships without a signature; files their vendor + # signed, such as the Microsoft runtime, keep that signature. See signpath/README.md. - name: Stage Windows binaries for SignPath id: stage-windows-binaries if: env.SIGN_WINDOWS == 'true' shell: pwsh run: | $version = "${{ env.ver }}" -replace '^V', '' - ./scripts/stage-for-signpath.ps1 -Bundle "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer" -Destination "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/unsigned-binaries" -ProductVersion $version + ./scripts/stage-for-signpath.ps1 -Bundle "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer" -Path "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/Uninstall.exe" -Destination "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/unsigned-binaries" -ProductVersion $version "version=$version" | Out-File -Append -FilePath $env:GITHUB_OUTPUT -Encoding utf8 - name: Upload unsigned Windows binaries for SignPath @@ -555,7 +569,7 @@ jobs: - name: Replace Windows binaries with signed output if: env.SIGN_WINDOWS == 'true' shell: pwsh - run: ./scripts/restore-from-signpath.ps1 -SignedDirectory "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-binaries" -Bundle "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer" + run: ./scripts/restore-from-signpath.ps1 -SignedDirectory "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-binaries" -Bundle "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer" -Exclude Uninstall.exe # NSIS is x86-only; it runs (and the installer it emits runs) under ARM64's # x86 emulation, packaging the native arm64 payload from build-arm64. @@ -568,16 +582,30 @@ jobs: working-directory: ${{ github.workspace }}/${{ env.BUILD_DIR }} run: | $bundle = "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer".Replace('\', '/') - cpack -G NSIS -D CPACK_INSTALL_CMAKE_PROJECTS= -D "CPACK_INSTALLED_DIRECTORIES=$bundle;." + $signedUninstaller = @() + if ($env:SIGN_WINDOWS -eq 'true') { + $uninstaller = "${{ github.workspace }}/${{ env.BUILD_DIR }}/signpath/signed-binaries/Uninstall.exe".Replace('\', '/') + $signedUninstaller = "-D", "CPACK_NSIS_EXECUTABLE_PRE_ARGUMENTS=/DORCA_SIGNED_UNINSTALLER=$uninstaller" + } + cpack -G NSIS -D CPACK_INSTALL_CMAKE_PROJECTS= -D "CPACK_INSTALLED_DIRECTORIES=$bundle;." @signedUninstaller - # cpack stages what the installer packs under _CPack_Packages//NSIS/; - # every executable and DLL there must carry a valid signature, ours or its vendor's. - - name: Verify every binary the installer packs is signed + # Installs into a scratch directory and checks that every executable and DLL an + # installation puts on disk, the uninstaller included, carries a valid signature, + # ours or its vendor's. The hosted runner is discarded afterwards, so the + # installation is left in place. + - name: Verify every binary the installer installs is signed if: env.SIGN_WINDOWS == 'true' shell: pwsh run: | - $payload = Get-Item "${{ github.workspace }}/${{ env.BUILD_DIR }}/_CPack_Packages/*/NSIS/OrcaSlicer_Windows_Installer_${{ env.ver }}${{ env.ARCH_SUFFIX }}" - ./scripts/verify-authenticode.ps1 -ArtifactDirectory $payload.FullName + $installDir = Join-Path "${{ runner.temp }}" "orca-install-check" + $installer = "${{ github.workspace }}/${{ env.BUILD_DIR }}/OrcaSlicer_Windows_Installer_${{ env.ver }}${{ env.ARCH_SUFFIX }}.exe" + # NSIS requires /D= to come last. + $setup = Start-Process -FilePath $installer -ArgumentList "/S", "/D=$installDir" -Wait -PassThru + if ($setup.ExitCode -ne 0) { throw "The installer exited with code $($setup.ExitCode)." } + if (-not (Test-Path -LiteralPath (Join-Path $installDir "Uninstall.exe") -PathType Leaf)) { + throw "The installation in '$installDir' has no Uninstall.exe." + } + ./scripts/verify-authenticode.ps1 -ArtifactDirectory $installDir - name: Stage Windows installer for SignPath if: env.SIGN_WINDOWS == 'true' diff --git a/CMakeLists.txt b/CMakeLists.txt index 5bea14e54a..fd25e7feac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1355,10 +1355,13 @@ set (CPACK_NSIS_EXECUTABLES_DIRECTORY ".") set(CPACK_PACKAGE_EXECUTABLES "orca-slicer;OrcaSlicer") set(CPACK_CREATE_DESKTOP_LINKS "orca-slicer") set (CPACK_RESOURCE_FILE_LICENSE ${CMAKE_SOURCE_DIR}/LICENSE.txt) # must also include in install command -# Version information of the installer, the same as the binaries it installs carry -# (src/dev-utils/platform/msw/OrcaSlicer-version.rc.in): SignPath only signs it when -# ProductName and ProductVersion match signpath/windows-installer-v1.xml. The NSIS strings -# are single-quoted because CPack writes this value into CPackConfig.cmake unescaped. +# Version information of the installer and of the uninstaller it writes, the same as the +# binaries it installs carry (src/dev-utils/platform/msw/OrcaSlicer-version.rc.in): +# SignPath only signs them when ProductName and ProductVersion match the artifact +# configurations in signpath/. The include lets the release workflow embed a signed +# uninstaller. The NSIS strings are single-quoted and the paths use forward slashes +# because CPack writes this value into CPackConfig.cmake unescaped; !include only accepts +# a forward-slash path through !addincludedir. string (CONCAT CPACK_NSIS_DEFINES "VIProductVersion '${ORCA_VERSION_MAJOR}.${ORCA_VERSION_MINOR}.${ORCA_VERSION_PATCH}.0'\n" "VIFileVersion '${ORCA_VERSION_MAJOR}.${ORCA_VERSION_MINOR}.${ORCA_VERSION_PATCH}.0'\n" @@ -1366,7 +1369,9 @@ string (CONCAT CPACK_NSIS_DEFINES "VIAddVersionKey /LANG=1033 'FileDescription' '${SLIC3R_APP_NAME} Installer'\n" "VIAddVersionKey /LANG=1033 'FileVersion' '${SoftFever_VERSION}'\n" "VIAddVersionKey /LANG=1033 'ProductName' '${SLIC3R_APP_NAME}'\n" - "VIAddVersionKey /LANG=1033 'ProductVersion' '${SoftFever_VERSION}'\n") + "VIAddVersionKey /LANG=1033 'ProductVersion' '${SoftFever_VERSION}'\n" + "!addincludedir '${CMAKE_SOURCE_DIR}/src/dev-utils/platform/msw'\n" + "!include 'uninstaller-signing.nsh'\n") set(CPACK_WIX_UPGRADE_GUID "058245e8-20e0-4a95-9ab7-1acfe17ad511") set(CPACK_GENERATOR NSIS) diff --git a/scripts/restore-from-signpath.ps1 b/scripts/restore-from-signpath.ps1 index e53e7d652f..f57a8f85f9 100644 --- a/scripts/restore-from-signpath.ps1 +++ b/scripts/restore-from-signpath.ps1 @@ -1,7 +1,8 @@ # Copies the files SignPath signed back over the originals in the portable bundle they # were staged from with stage-for-signpath.ps1 -Bundle: the root of the signed artifact # holds OrcaSlicer's own binaries, third-party/ the other files under their paths in the -# bundle. +# bundle. Root files named in -Exclude came from outside the bundle, such as the exported +# uninstaller, and stay in -SignedDirectory. [CmdletBinding()] param( [Parameter(Mandatory = $true)] @@ -10,14 +11,17 @@ param( [Parameter(Mandatory = $true)] [ValidateScript({ Test-Path -LiteralPath $_ -PathType Container })] - [string]$Bundle + [string]$Bundle, + + [string[]]$Exclude = @() ) $ErrorActionPreference = "Stop" $signedRoot = (Resolve-Path -LiteralPath $SignedDirectory).Path.TrimEnd('\', '/') $thirdPartyPrefix = "third-party" + [IO.Path]::DirectorySeparatorChar -$files = Get-ChildItem -LiteralPath $signedRoot -Recurse -File +$files = Get-ChildItem -LiteralPath $signedRoot -Recurse -File | + Where-Object { $_.DirectoryName -ne $signedRoot -or $_.Name -notin $Exclude } if (-not $files) { throw "No signed files found in '$signedRoot'." } @@ -37,4 +41,4 @@ foreach ($file in $files) { Copy-Item -LiteralPath $file.FullName -Destination $target -Force } -Write-Host "Restored $($files.Count) signed files into '$Bundle'." +Write-Host "Restored $(@($files).Count) signed files into '$Bundle'." diff --git a/scripts/stage-for-signpath.ps1 b/scripts/stage-for-signpath.ps1 index 6636f468e3..ee94b119ec 100644 --- a/scripts/stage-for-signpath.ps1 +++ b/scripts/stage-for-signpath.ps1 @@ -7,14 +7,16 @@ # -Path stages the given files at the root of -Destination. # # -Bundle stages a portable OrcaSlicer bundle for signpath/windows-binaries-v1.xml: -# OrcaSlicer's own binaries (-OwnBinaries) at the root, where their version information -# is checked, and every other executable, DLL and Python extension module that carries no +# OrcaSlicer's own binaries (-OwnBinaries, plus any -Path files from outside the bundle, +# such as the exported uninstaller) at the root, where their version information is +# checked, and every other executable, DLL and Python extension module that carries no # signature under third-party/, at its path in the bundle. Files signed by their vendor, # such as the Microsoft runtime, keep that signature. restore-from-signpath.ps1 copies the # signed files back. [CmdletBinding(DefaultParameterSetName = "Path")] param( [Parameter(Mandatory = $true, ParameterSetName = "Path")] + [Parameter(ParameterSetName = "Bundle")] [string[]]$Path, [Parameter(Mandatory = $true, ParameterSetName = "Bundle")] @@ -50,21 +52,20 @@ function Test-VersionInfo { New-Item -ItemType Directory -Force -Path $Destination | Out-Null -if ($PSCmdlet.ParameterSetName -eq "Path") { - foreach ($file in $Path) { - Test-VersionInfo $file - Copy-Item -LiteralPath $file -Destination $Destination -Force - Write-Host "Staged $(Split-Path -Leaf $file) ($ProductName $ProductVersion)" - } - exit 0 +$ownFiles = @($Path | Where-Object { $_ }) +if ($PSCmdlet.ParameterSetName -eq "Bundle") { + $bundleRoot = (Resolve-Path -LiteralPath $Bundle).Path.TrimEnd('\', '/') + $ownFiles = @($OwnBinaries | ForEach-Object { Join-Path $bundleRoot $_ }) + $ownFiles } -$bundleRoot = (Resolve-Path -LiteralPath $Bundle).Path.TrimEnd('\', '/') -foreach ($name in $OwnBinaries) { - $file = Join-Path $bundleRoot $name +foreach ($file in $ownFiles) { Test-VersionInfo $file Copy-Item -LiteralPath $file -Destination $Destination -Force - Write-Host "Staged $name ($ProductName $ProductVersion)" + Write-Host "Staged $(Split-Path -Leaf $file) ($ProductName $ProductVersion)" +} + +if ($PSCmdlet.ParameterSetName -eq "Path") { + exit 0 } $thirdParty = Get-ChildItem -LiteralPath $bundleRoot -Recurse -File | diff --git a/signpath/README.md b/signpath/README.md index fb22f56195..2b26a834b6 100644 --- a/signpath/README.md +++ b/signpath/README.md @@ -6,16 +6,18 @@ Every signing request uses the `release-signing` policy and waits in SignPath un ## What gets signed -Every executable, DLL and Python extension module in the bundle ends up with a valid signature, so Windows features that check each loaded binary, such as Smart App Control, find none unsigned. Files their vendor already signed keep that signature: the Microsoft runtime DLLs and `WebView2Loader.dll` (Microsoft) and CPython's bundled OpenSSL and libffi (Python Software Foundation). Everything else is signed with the project's certificate: OrcaSlicer's own `orca-slicer.exe` and `OrcaSlicer.dll`, the libraries built from upstream sources (OCCT, FFmpeg, FreeType, GMP, MPFR, CPython), the downloaded `uv.exe`, and the NSIS installer. +Every executable, DLL and Python extension module in the bundle ends up with a valid signature, so Windows features that check each loaded binary, such as Smart App Control, find none unsigned. Files their vendor already signed keep that signature: the Microsoft runtime DLLs and `WebView2Loader.dll` (Microsoft) and CPython's bundled OpenSSL and libffi (Python Software Foundation). Everything else is signed with the project's certificate: OrcaSlicer's own `orca-slicer.exe` and `OrcaSlicer.dll`, the libraries built from upstream sources (OCCT, FFmpeg, FreeType, GMP, MPFR, CPython), the downloaded `uv.exe`, and the NSIS installer with the `Uninstall.exe` it installs. The SignPath Foundation's conditions ask projects to sign only binaries built from their own source and to leave upstream projects' binaries unsigned, which they allow inside a signed installer. Signing the upstream libraries departs from that, by the maintainers' choice. Narrowing the signed set back to OrcaSlicer's own files means dropping the `third-party` file set from `windows-binaries-v1` and the third-party staging from `scripts/stage-for-signpath.ps1`. -The Foundation also requires metadata restrictions on signed files. OrcaSlicer's own binaries and the installer must have the ProductName `OrcaSlicer` and the ProductVersion passed in with the request, which the workflow takes from `SoftFever_VERSION` in `version.inc`. The binaries get that version information from `src/dev-utils/platform/msw/OrcaSlicer-version.rc.in`, the installer from `CPACK_NSIS_DEFINES` in `CMakeLists.txt`. The upstream files carry their own projects' metadata, so their file set has no restriction. `scripts/stage-for-signpath.ps1` checks the version information before a request is submitted, since SignPath only rejects a mismatch after the request has been approved. +The Foundation also requires metadata restrictions on signed files. OrcaSlicer's own binaries, the installer and the uninstaller must have the ProductName `OrcaSlicer` and the ProductVersion passed in with the request, which the workflow takes from `SoftFever_VERSION` in `version.inc`. The binaries get that version information from `src/dev-utils/platform/msw/OrcaSlicer-version.rc.in`, the installer and its uninstaller from `CPACK_NSIS_DEFINES` in `CMakeLists.txt`. The upstream files carry their own projects' metadata, so their file set has no restriction. `scripts/stage-for-signpath.ps1` checks the version information before a request is submitted, since SignPath only rejects a mismatch after the request has been approved. ## `windows-binaries-v1` -Signs the binaries of the installed bundle, `${{ env.BUILD_DIR }}/OrcaSlicer`. `scripts/stage-for-signpath.ps1 -Bundle` stages them for upload: `orca-slicer.exe` and `OrcaSlicer.dll` at the root of the artifact ZIP, where the metadata restrictions apply, and every other unsigned `.exe`, `.dll` and `.pyd` under `third-party/`, at its path in the bundle. `scripts/restore-from-signpath.ps1` copies the signed files back over the originals. The portable ZIP, the MSIX package and the installer are all packed from that bundle; `cpack` is pointed at it with `CPACK_INSTALLED_DIRECTORIES` rather than re-running the install rules, which would take CPython and `uv.exe` from their unsigned sources again. A check after `cpack` verifies that every binary in the installer's payload is signed. +Signs the binaries of the installed bundle, `${{ env.BUILD_DIR }}/OrcaSlicer`, and the installer's uninstaller. `scripts/stage-for-signpath.ps1 -Bundle` stages them for upload: `orca-slicer.exe`, `OrcaSlicer.dll` and `Uninstall.exe` at the root of the artifact ZIP, where the metadata restrictions apply, and every other unsigned `.exe`, `.dll` and `.pyd` of the bundle under `third-party/`, at its path in the bundle. `scripts/restore-from-signpath.ps1` copies the signed files back over the originals. The portable ZIP, the MSIX package and the installer are all packed from that bundle; `cpack` is pointed at it with `CPACK_INSTALLED_DIRECTORIES` rather than re-running the install rules, which would take CPython and `uv.exe` from their unsigned sources again. + +makensis generates the uninstaller while it compiles the installer, so it cannot be signed after the fact like the other files. Before the binaries are submitted, the workflow builds the installer once to export its uninstaller, and the final `cpack` embeds the signed copy in its place, through the hooks in `src/dev-utils/platform/msw/uninstaller-signing.nsh`. Both builds pack the same files with the same script, so the uninstaller is the same one the final installer would have generated. After the final `cpack`, the workflow installs the installer silently into a scratch directory on the runner and verifies that every binary the installation puts on disk, the uninstaller included, carries a valid signature. ## `windows-installer-v1` -Signs the NSIS installer `cpack` produces, `OrcaSlicer_Windows_Installer__.exe`, uploaded as the only file of its artifact. The uninstaller NSIS writes during installation is not signed. +Signs the NSIS installer `cpack` produces, `OrcaSlicer_Windows_Installer__.exe`, uploaded as the only file of its artifact. diff --git a/signpath/windows-binaries-v1.xml b/signpath/windows-binaries-v1.xml index 7c353dad83..8d5d23fffd 100644 --- a/signpath/windows-binaries-v1.xml +++ b/signpath/windows-binaries-v1.xml @@ -7,6 +7,7 @@ + diff --git a/src/dev-utils/platform/msw/uninstaller-signing.nsh b/src/dev-utils/platform/msw/uninstaller-signing.nsh new file mode 100644 index 0000000000..70e5af18bf --- /dev/null +++ b/src/dev-utils/platform/msw/uninstaller-signing.nsh @@ -0,0 +1,19 @@ +; Lets the Windows release workflow ship a signed uninstaller (see signpath/README.md). +; makensis generates the uninstaller while it compiles the installer, so it cannot be +; signed afterwards like the installed binaries. Instead a first compile exports it +; (/DORCA_EXPORT_UNINSTALLER=), the export is signed together with the binaries, and +; the final compile embeds the signed copy in place of the uninstaller it generates +; (/DORCA_SIGNED_UNINSTALLER=). Both compiles run the same script over the same +; installed files, so they generate the same uninstaller. A failed copy aborts the +; compile rather than leave the uninstaller unsigned. +; +; cpack passes the paths with forward slashes, which cmd's copy does not accept for its +; source, so they are converted first. +!ifdef ORCA_EXPORT_UNINSTALLER + !searchreplace ORCA_EXPORT_UNINSTALLER_PATH "${ORCA_EXPORT_UNINSTALLER}" "/" "\" + !uninstfinalize 'cmd /c copy /y "%1" "${ORCA_EXPORT_UNINSTALLER_PATH}"' = 0 +!endif +!ifdef ORCA_SIGNED_UNINSTALLER + !searchreplace ORCA_SIGNED_UNINSTALLER_PATH "${ORCA_SIGNED_UNINSTALLER}" "/" "\" + !uninstfinalize 'cmd /c copy /y "${ORCA_SIGNED_UNINSTALLER_PATH}" "%1"' = 0 +!endif