diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index 9272f4e..1a1d173 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -159,6 +159,9 @@ jobs: - name: Test run: dotnet test Devolutions.Terminal.slnx -c Release --no-build -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + - name: Test macOS legal notice layout + run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsLegalNotices.ps1 + nuget-pack: name: Pack Devolutions.Terminal.Control NuGet package runs-on: windows-latest @@ -313,6 +316,9 @@ jobs: - name: Test macOS code-signing topology run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsCodeSigning.ps1 + - name: Test macOS legal notice layout + run: pwsh -NoLogo -NoProfile -File scripts/Test-MacOsLegalNotices.ps1 + macos-native-aot: name: macOS NativeAOT ${{ matrix.rid }} runs-on: macos-26 @@ -706,6 +712,10 @@ jobs: with: version: v0.6.1 + - name: Test MSIX publisher and development signing + shell: pwsh + run: ./src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 + - name: Download x64 publish uses: actions/download-artifact@v4 with: @@ -718,14 +728,6 @@ jobs: name: DevolutionsTerminal-win-arm64 path: artifacts/msix/layout/win-arm64 - - name: Build unsigned MSIX packages - shell: pwsh - run: > - ./src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 - -SkipPublish - -OutputDirectory ./artifacts/msix - -Version "${{ needs.release-metadata.outputs.msix_version }}" - - name: Resolve signing mode id: signing-mode shell: pwsh @@ -737,6 +739,7 @@ jobs: TRUSTED_SIGNING_ENDPOINT: ${{ secrets.TRUSTED_SIGNING_ENDPOINT }} TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} TRUSTED_SIGNING_PROFILE_NAME: ${{ secrets.TRUSTED_SIGNING_PROFILE_NAME }} + REQUESTED_PUBLISHER: ${{ vars.TRUSTED_SIGNING_PUBLISHER }} run: | # Ordinary (non-release) CI runs never attempt MSIX signing: they are not gated on # signing secrets being configured, so they must not fail when those secrets are absent. @@ -774,7 +777,40 @@ jobs: throw "Missing Azure Artifact Signing secrets: $($missing -join ', ')" } - "should_sign=true" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + # Resolve after the selected GitHub Environment's variables are available. + $publisher = $env:REQUESTED_PUBLISHER + if ([string]::IsNullOrWhiteSpace($publisher)) { + $publisher = "CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Québec, C=CA" + } + if ($publisher -match '[\r\n]') { + throw "TRUSTED_SIGNING_PUBLISHER must be a single-line certificate subject." + } + @( + "should_sign=true" + "publisher=$publisher" + ) | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + + - name: Build unsigned MSIX packages + id: build-msix + shell: pwsh + env: + SHOULD_SIGN: ${{ steps.signing-mode.outputs.should_sign }} + MSIX_VERSION: ${{ needs.release-metadata.outputs.msix_version }} + MSIX_PUBLISHER: ${{ steps.signing-mode.outputs.publisher }} + run: | + $arguments = @{ + SkipPublish = $true + OutputDirectory = "./artifacts/msix" + Version = $env:MSIX_VERSION + } + if ($env:SHOULD_SIGN -eq "true") { + $arguments["Publisher"] = $env:MSIX_PUBLISHER + } + ./src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 @arguments + + New-Item -ItemType Directory -Force -Path ./artifacts/msix/unsigned | Out-Null + Get-ChildItem ./artifacts/msix/packages -Filter "*.msix" -File | + Copy-Item -Destination ./artifacts/msix/unsigned - name: Azure login for Trusted Signing if: steps.signing-mode.outputs.should_sign == 'true' @@ -792,15 +828,8 @@ jobs: TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.TRUSTED_SIGNING_ACCOUNT_NAME }} TRUSTED_SIGNING_PROFILE_NAME: ${{ secrets.TRUSTED_SIGNING_PROFILE_NAME }} run: | - # MSIX packages are signed here, on a real Windows runner, using WinApp CLI's - # native `az-sign` command (real Win32 SignerSignEx3 / AppxSip.dll) rather than - # psign-tool's cross-platform "--mode portable" MSIX signing path used for the - # .msi container and bundled binaries elsewhere in this workflow. Portable-mode - # MSIX signing has been found to corrupt the package's central directory relative - # to the AXCD digest embedded in its own AppxSignature.p7x, which Windows rejects - # at install time with HRESULT 0x80080205 ("The Appx package's block map is - # invalid") even though the package is otherwise well-formed. Native signing on - # Windows uses the real OS AppX signing component and does not have this bug. + # Keep MSIX signing and verification on Windows with the native AppX SIP. + # psign-tool signs only the MSI container and bundled binaries in other jobs. $metadata = [ordered]@{ Endpoint = $env:TRUSTED_SIGNING_ENDPOINT CodeSigningAccountName = $env:TRUSTED_SIGNING_ACCOUNT_NAME @@ -821,6 +850,7 @@ jobs: shell: pwsh env: SHOULD_SIGN: ${{ steps.signing-mode.outputs.should_sign }} + MSIX_PUBLISHER: ${{ steps.signing-mode.outputs.publisher }} run: | $packagePaths = Get-ChildItem ./artifacts/msix/packages -File | Where-Object Extension -eq ".msix" | @@ -831,10 +861,35 @@ jobs: } if ($env:SHOULD_SIGN -eq "true") { $arguments["RequireSignature"] = $true + $arguments["ExpectedPublisher"] = $env:MSIX_PUBLISHER } ./src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 @arguments + - name: Collect MSIX failure diagnostics + if: failure() && steps.build-msix.outcome == 'success' + shell: pwsh + run: | + $diagnostics = "./artifacts/msix/diagnostics" + New-Item -ItemType Directory -Force -Path $diagnostics | Out-Null + Copy-Item ./artifacts/msix/metadata/Package.appxmanifest -Destination $diagnostics + wevtutil qe Microsoft-Windows-AppxPackaging/Operational /rd:true /c:30 /f:xml | + Set-Content "$diagnostics/AppxPackaging-events.xml" -Encoding utf8 + if ($LASTEXITCODE -ne 0) { + Write-Host "::warning::AppxPackaging event collection failed with exit code $LASTEXITCODE." + } + + - name: Upload failed MSIX inputs and diagnostics + if: failure() && steps.build-msix.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-MSIX-failure-diagnostics + path: | + artifacts/msix/unsigned + artifacts/msix/diagnostics + if-no-files-found: warn + retention-days: 7 + - name: Upload MSIX artifacts uses: actions/upload-artifact@v4 with: @@ -1336,13 +1391,8 @@ jobs: throw "Failed to acquire an Azure Trusted Signing access token." } - # MSIX packages are already signed on windows-latest in the msix job (via - # WinApp CLI's native `az-sign`), not here. psign-tool's cross-platform - # "--mode portable" MSIX signing (which is what this ubuntu-latest job would - # otherwise use) has been found to corrupt the package's central directory - # relative to the AXCD digest embedded in its own AppxSignature.p7x, which - # Windows rejects at install time with HRESULT 0x80080205 ("The Appx - # package's block map is invalid"). Only the .msi container is signed here. + # MSIX packages are already signed and verified by the Windows msix job. + # Only the MSI container is signed in this Linux release job. ./src/Devolutions.Terminal.Package/Scripts/Sign-Packages.ps1 ` -PackageDirectory ./artifacts/msi-packages ` -Version $env:MSIX_VERSION ` diff --git a/docs/macos.md b/docs/macos.md index 4f887b3..54b98cf 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -63,6 +63,9 @@ an additional SVG renderer. Xcode compiles that same master through remains the fallback on earlier releases. The script then ad-hoc signs the bundle and writes a zip plus SHA-256 manifest. +Published `THIRD-PARTY-NOTICES*.txt` files (including transitive dependency notices) are preserved in `Contents/Resources` alongside `LICENSE`, not in the code-only `Contents/MacOS` directory. +`Test-MacOsLegalNotices.ps1` checks this layout without requiring Apple signing credentials. + ```bash open "artifacts/packages/Devolutions Terminal.app" ``` diff --git a/docs/release.md b/docs/release.md index 16acc80..708def0 100644 --- a/docs/release.md +++ b/docs/release.md @@ -226,14 +226,13 @@ credentials are unavailable. ## GitHub Release automation -The release workflow in `.github/workflows/build-terminal.yml` publishes signed -Windows packages and the corresponding platform archives directly to GitHub -Releases without staging them in OneDrive. It builds unsigned per-architecture -MSIX and MSI packages for Windows x64 and ARM64, then signs them on the Linux -release runner with Devolutions `psign-tool` and Azure Artifact Signing -(Trusted Signing). The private key never lands on the runner. Signed Windows -packages are uploaded alongside Linux and macOS archives. The workflow is -intended for tag-based releases and for manual dispatch. +The release workflow in `.github/workflows/build-terminal.yml` publishes signed Windows packages and the corresponding platform archives directly to GitHub Releases without staging them in OneDrive. +It builds unsigned per-architecture MSIX and MSI packages for Windows x64 and ARM64. +MSIX uses WinApp CLI's native `az-sign` command and Windows SDK verification on the Windows packaging runner. +MSI containers and their application binaries use Devolutions `psign-tool` with Azure Artifact Signing (Trusted Signing). +The production private key never lands on the runner. +Signed Windows packages are uploaded alongside Linux and macOS archives. +The workflow is intended for tag-based releases and for manual dispatch. Manual dispatch provides these inputs: @@ -244,9 +243,8 @@ Manual dispatch provides these inputs: - `dry_run` — build, package, and validate artifacts without creating or updating a GitHub Release. The combined release assets are uploaded as a workflow artifact. -- `sign_dry_run` — with `dry_run`, sign Windows packages using the selected - signing environment when all signing secrets are available. This validates - the real `psign-tool` and Azure Artifact Signing path without publishing. +- `sign_dry_run` — with `dry_run`, sign Windows packages using the selected signing environment when all signing secrets are available. + This validates the real WinApp CLI, `psign-tool`, and Azure Artifact Signing paths without publishing. Without this option, dry-run assets remain unsigned. - `github-env` — selects the GitHub Environment containing signing credentials: `test`, `prod`, or `auto`. `auto` selects `publish-prod` for `master` and tag @@ -279,13 +277,20 @@ Required environment secrets: - `APPLE_APP_DEV_ID_APP_CERTIFICATE_PASSWORD` — Developer ID certificate password - `APPLE_BOT_PASSWORD` — app-specific password for macOS notarization -Optional environment or repository variable: +Optional GitHub Environment or repository configuration variables: - `TRUSTED_SIGNING_TIMESTAMP_SERVER` (defaults to `http://timestamp.acs.microsoft.com/`) +- `TRUSTED_SIGNING_PUBLISHER` — full certificate subject for the selected signing profile. + Defaults to `CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Québec, C=CA`; override it if the profile has a different subject. -`psign-tool` portable Artifact Signing signs the per-architecture `.msix` and -`.msi` files. The MSIX `Publisher` identity in `Package.appxmanifest` must -match the Artifact Signing certificate subject. +The signed MSIX `Identity.Publisher` must exactly match the Artifact Signing certificate subject, including all DN fields, punctuation, and accents. +The workflow resolves whether signing will occur before building the package and supplies this publisher to `Build-Packages.ps1`; unsigned CI/dry runs retain the checked-in development publisher. +`Test-Packages.ps1` checks both the generated identity and the signer against the selected publisher. +Development certificate generation still uses the unchanged source manifest. +Changing publishers changes the package family, so a signed production package is not an in-place upgrade of a development package. + +If MSIX signing or validation fails after packaging, the workflow retains the pre-signing unsigned packages, generated manifest, and recent AppxPackaging events in `DevolutionsTerminal-MSIX-failure-diagnostics` for seven days. +HRESULT `0x8007000B` alone is not diagnostic: AppxPackaging events distinguish publisher mismatch (150), hash mismatch (151), and block-map mismatch (152). The release job always publishes `Devolutions.Terminal.App` and `Devolutions.Terminal.Control` to NuGet.org through OIDC trusted publishing diff --git a/scripts/MacOsPackagingCommon.psm1 b/scripts/MacOsPackagingCommon.psm1 index 5d47caa..09e5cd5 100644 --- a/scripts/MacOsPackagingCommon.psm1 +++ b/scripts/MacOsPackagingCommon.psm1 @@ -286,6 +286,20 @@ function Get-MacOsWritableDmgSizeMegabytes { return [Math]::Max([long]64, $sourceMegabytes + $extraMegabytes) } +function Move-MacOsLegalNotices { + param( + [Parameter(Mandatory)] + [string]$MacOsDirectory, + [Parameter(Mandatory)] + [string]$ResourcesDirectory + ) + + # Publish output can acquire new notices from transitive dependencies. + # Keep them out of the bundle's code-only directory without dropping licenses. + Get-ChildItem -LiteralPath $MacOsDirectory -File -Filter 'THIRD-PARTY-NOTICES*.txt' | + Move-Item -Destination $ResourcesDirectory -Force +} + Export-ModuleMember -Function ` Import-MacOsPackageEnv, ` Get-MacOsSourceDateEpoch, ` @@ -298,4 +312,5 @@ Export-ModuleMember -Function ` Get-Sha256Manifest, ` Set-MacOsReproducibleTimestamps, ` Get-MacOsTreeByteSize, ` - Get-MacOsWritableDmgSizeMegabytes + Get-MacOsWritableDmgSizeMegabytes, ` + Move-MacOsLegalNotices diff --git a/scripts/Stage-MacOsApp.ps1 b/scripts/Stage-MacOsApp.ps1 index 8bab75d..c2ecce4 100644 --- a/scripts/Stage-MacOsApp.ps1 +++ b/scripts/Stage-MacOsApp.ps1 @@ -143,16 +143,12 @@ finally { } Copy-Item -LiteralPath (Join-Path $repoRoot 'LICENSE') -Destination (Join-Path $resources 'LICENSE') -Force -Move-Item -LiteralPath (Join-Path $macosDir 'THIRD-PARTY-NOTICES-GHOSTTY.txt') ` - -Destination (Join-Path $resources 'THIRD-PARTY-NOTICES-GHOSTTY.txt') -Force -Move-Item -LiteralPath (Join-Path $macosDir 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt') ` - -Destination (Join-Path $resources 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt') -Force -Invoke-Native -FilePath chmod -ArgumentList @( - '0644', - (Join-Path $resources 'LICENSE'), - (Join-Path $resources 'THIRD-PARTY-NOTICES-GHOSTTY.txt'), - (Join-Path $resources 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt') +Move-MacOsLegalNotices -MacOsDirectory $macosDir -ResourcesDirectory $resources +$legalPaths = @((Join-Path $resources 'LICENSE')) + @( + Get-ChildItem -LiteralPath $resources -File -Filter 'THIRD-PARTY-NOTICES*.txt' | + ForEach-Object FullName ) +Invoke-Native -FilePath chmod -ArgumentList (@('0644') + $legalPaths) Invoke-Native -FilePath chmod -ArgumentList @( '0755', diff --git a/scripts/Test-MacOsCodeSigning.ps1 b/scripts/Test-MacOsCodeSigning.ps1 index 228addc..56717e0 100644 --- a/scripts/Test-MacOsCodeSigning.ps1 +++ b/scripts/Test-MacOsCodeSigning.ps1 @@ -80,6 +80,25 @@ try { } Remove-Item -LiteralPath (Join-Path $macosDir 'HelperTool.runtimeconfig.json') -Force + $noticeName = 'THIRD-PARTY-NOTICES-CONPTY.txt' + $noticeText = 'ConPTY fixture license must survive bundle signing.' + Set-Content -LiteralPath (Join-Path $macosDir $noticeName) -Value $noticeText -NoNewline -Encoding utf8 + $noticeRejected = $false + try { + & (Join-Path $scriptDir 'Sign-MacOsPackage.ps1') $appPath '-' + } + catch { + if ($_.Exception.Message -notmatch "Contents/MacOS may contain only Mach-O code.*$([regex]::Escape($noticeName))") { + throw + } + $noticeRejected = $true + } + if (-not $noticeRejected) { + throw 'Sign-MacOsPackage.ps1 accepted a third-party notice in Contents/MacOS.' + } + $resources = Join-Path $contents 'Resources' + New-Item -ItemType Directory -Path $resources | Out-Null + Move-MacOsLegalNotices -MacOsDirectory $macosDir -ResourcesDirectory $resources & (Join-Path $scriptDir 'Sign-MacOsPackage.ps1') $appPath '-' if ($LASTEXITCODE -ne 0) { throw "Sign-MacOsPackage.ps1 failed with exit code $LASTEXITCODE." @@ -90,6 +109,9 @@ try { $isolatedHelper = Join-Path $work 'HelperTool' Invoke-Native -FilePath cp -ArgumentList (Join-Path $macosDir 'HelperTool'), $isolatedHelper Assert-MacOsCodeSignature -Path $isolatedHelper -RequireHardenedRuntime + if ((Get-Content -LiteralPath (Join-Path $resources $noticeName) -Raw) -cne $noticeText) { + throw 'Bundle signing did not preserve the relocated ConPTY legal notice.' + } Write-Host 'macOS standalone auxiliary-code signing regression test passed.' } diff --git a/scripts/Test-MacOsLegalNotices.ps1 b/scripts/Test-MacOsLegalNotices.ps1 new file mode 100644 index 0000000..b928a37 --- /dev/null +++ b/scripts/Test-MacOsLegalNotices.ps1 @@ -0,0 +1,59 @@ +#!/usr/bin/env pwsh +#Requires -Version 7 +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module (Join-Path $PSScriptRoot 'MacOsPackagingCommon.psm1') -Force +$work = Join-Path ([IO.Path]::GetTempPath()) "macos-notices-test-$([guid]::NewGuid().ToString('N'))" + +try { + foreach ($rid in @('osx-x64', 'osx-arm64')) { + $contents = Join-Path $work "$rid/Notice Test.app/Contents" + $macos = Join-Path $contents 'MacOS' + $resources = Join-Path $contents 'Resources' + New-Item -ItemType Directory -Path $macos, $resources -Force | Out-Null + $codePath = Join-Path $macos 'dt' + [IO.File]::WriteAllBytes($codePath, [byte[]](0xCF, 0xFA, 0xED, 0xFE)) + $codeHash = (Get-FileHash -LiteralPath $codePath).Hash + $notices = @( + 'THIRD-PARTY-NOTICES-GHOSTTY.txt', + 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt', + 'THIRD-PARTY-NOTICES-CONPTY.txt', + 'THIRD-PARTY-NOTICES-FUTURE-DEPENDENCY.txt' + ) + foreach ($notice in $notices) { + [IO.File]::WriteAllText((Join-Path $macos $notice), "License content for $notice") + } + + Move-MacOsLegalNotices -MacOsDirectory $macos -ResourcesDirectory $resources + foreach ($notice in $notices) { + $resourcePath = Join-Path $resources $notice + if (-not (Test-Path -LiteralPath $resourcePath -PathType Leaf) -or + [IO.File]::ReadAllText($resourcePath) -cne "License content for $notice") { + throw "$rid did not preserve the exact contents of $notice in Resources." + } + if (Test-Path -LiteralPath (Join-Path $macos $notice)) { + throw "$rid left $notice in the code-only MacOS directory." + } + } + if (@(Get-ChildItem -LiteralPath $resources -File).Count -ne $notices.Count -or + @(Get-ChildItem -LiteralPath $macos -File).Count -ne 1 -or + (Get-FileHash -LiteralPath $codePath).Hash -cne $codeHash) { + throw "$rid notice relocation changed the code or produced an unexpected layout." + } + Write-Host "Move-MacOsLegalNotices_AllPublishedNoticesPreserved ($rid) passed." + + Move-MacOsLegalNotices -MacOsDirectory $macos -ResourcesDirectory $resources + if (@(Get-ChildItem -LiteralPath $resources -File).Count -ne $notices.Count -or + (Get-FileHash -LiteralPath $codePath).Hash -cne $codeHash) { + throw "$rid notice-free relocation changed the existing layout." + } + Write-Host "Move-MacOsLegalNotices_NoNoticesLeavesCodeUntouched ($rid) passed." + } +} +finally { + Remove-Item -LiteralPath $work -Recurse -Force +} diff --git a/scripts/Test-MacOsPackage.ps1 b/scripts/Test-MacOsPackage.ps1 index 8124598..eb73006 100644 --- a/scripts/Test-MacOsPackage.ps1 +++ b/scripts/Test-MacOsPackage.ps1 @@ -163,6 +163,10 @@ function Test-MacOsAppBundle { if ($runtimeConfig) { throw "$label contains NativeAOT runtime configuration in Contents/MacOS." } + $misplacedNotices = Get-ChildItem -LiteralPath $macosDir -File -Filter 'THIRD-PARTY-NOTICES*.txt' + if ($misplacedNotices) { + throw "$label contains third-party legal notices in Contents/MacOS instead of Contents/Resources." + } } try { diff --git a/scripts/Test-MacOsPackagingMetadata.ps1 b/scripts/Test-MacOsPackagingMetadata.ps1 index fa830fd..c0a8738 100644 --- a/scripts/Test-MacOsPackagingMetadata.ps1 +++ b/scripts/Test-MacOsPackagingMetadata.ps1 @@ -25,6 +25,7 @@ $scripts = @( 'Test-MacOsPackage.ps1', 'Test-MacOsRuntime.ps1', 'Test-MacOsCodeSigning.ps1', + 'Test-MacOsLegalNotices.ps1', 'Sign-MacOsPackage.ps1', 'Build-MacOsDmg.ps1', 'Notarize-MacOsPackage.ps1', diff --git a/src/Devolutions.Terminal.Package/README.md b/src/Devolutions.Terminal.Package/README.md index f66cfe1..45796d1 100644 --- a/src/Devolutions.Terminal.Package/README.md +++ b/src/Devolutions.Terminal.Package/README.md @@ -31,15 +31,19 @@ registration. | Field | Value | | --- | --- | | NuGet distribution package | `Devolutions.Terminal.App` | -| Publisher | `CN=Devolutions Inc.` | +| Development publisher | `CN=Devolutions Inc.` | | Application ID | `Terminal` | | Execution aliases | `dt.exe`, `Devolutions.Terminal.exe` | | Protocol | `dterm:` | | Minimum Windows | Windows 10, version 2004 (`10.0.19041.0`) | -The identity is stable across local and CI builds so package-scoped data can -survive upgrades. Production/Store onboarding can replace the identity and -publisher in a separate manifest without changing the unpackaged host. +The checked-in identity is stable for development and unsigned CI builds. +Signed releases override the generated manifest's publisher with the complete Artifact Signing certificate subject: `CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Québec, C=CA`. +The workflow's optional `TRUSTED_SIGNING_PUBLISHER` GitHub configuration variable supports profiles with a different subject. +The source manifest and development certificate generation remain unchanged. + +A different publisher produces a different package family and is not an in-place upgrade of the development package; package-scoped data is separate. +This distinction does not affect the unpackaged host or MSI. The package is a medium-integrity, full-trust desktop package. It declares only `runFullTrust`; it does not request broad file-system or network capabilities. @@ -69,6 +73,10 @@ To package NativeAOT outputs produced elsewhere, place them in helpers. `-SkipNativeBuild` is only valid when matching helper outputs already exist under `artifacts\msix\native-shell\`. +For an external signing profile, pass its exact certificate subject through `Build-Packages.ps1 -Publisher` before packaging, and use the same value with `Test-Packages.ps1 -ExpectedPublisher`. +Do not modify a built MSIX manifest: that would invalidate its block map. +Omitting these parameters retains the development identity and its validation. + ## Development signing and installation Private keys and generated package artifacts live under `dotnet\artifacts`, diff --git a/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 b/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 index 4b2dbeb..ac2ded1 100644 --- a/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 +++ b/src/Devolutions.Terminal.Package/Scripts/Build-Packages.ps1 @@ -15,6 +15,10 @@ param( [switch] $SkipNativeBuild, + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) -and $_ -notmatch '[\r\n]' })] + [string] $Publisher, + [string] $CertificatePath, [securestring] $CertificatePassword @@ -89,6 +93,9 @@ function Write-VersionedManifest { [xml] $manifest = Get-Content -LiteralPath $sourceManifest $manifest.Package.Identity.Version = $Version + if (-not [string]::IsNullOrWhiteSpace($Publisher)) { + $manifest.Package.Identity.Publisher = $Publisher + } $settings = [Xml.XmlWriterSettings]::new() $settings.Encoding = [Text.UTF8Encoding]::new($false) diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 new file mode 100644 index 0000000..454cb7b --- /dev/null +++ b/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 @@ -0,0 +1,197 @@ +#Requires -Version 7 +[CmdletBinding()] +param() + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if (-not $IsWindows) { + throw "MSIX build and local signing regression tests require Windows." +} +if (-not (Get-Command winapp -ErrorAction SilentlyContinue)) { + throw "MSIX build regression tests require WinApp CLI." +} + +$packageRoot = Split-Path -Parent $PSScriptRoot +$sourceManifest = Join-Path $packageRoot "Package.appxmanifest" +$sourceHash = (Get-FileHash -LiteralPath $sourceManifest).Hash +$work = Join-Path ([IO.Path]::GetTempPath()) "terminal-msix-test-$([guid]::NewGuid().ToString('N'))" +$productionPublisher = "CN=Devolutions Inc, O=Devolutions Inc, L=Lavaltrie, S=Qu$([char]0xE9)bec, C=CA" +$password = ConvertTo-SecureString "ephemeral-fixture-password" -AsPlainText -Force +$wrongPublisherPackage = Join-Path $work "wrong-publisher.msix" + +function Assert-ExpectedFailure { + param( + [scriptblock] $Action, + [string] $MessagePattern, + [string] $Name + ) + + try { + & $Action | Out-Null + } + catch { + if ($_.Exception.Message -notmatch $MessagePattern) { + throw + } + Write-Host "$Name passed." + return + } + throw "$Name did not reject the invalid input." +} + +function New-PackageFixture { + param([string] $OutputDirectory) + + foreach ($architecture in @("x64", "arm64")) { + $layout = Join-Path $OutputDirectory "layout\win-$architecture" + $native = Join-Path $OutputDirectory "native-shell\$architecture" + New-Item -ItemType Directory -Path $layout, $native -Force | Out-Null + # Non-runnable PE headers are sufficient for architecture detection and packing. + $image = [byte[]]::new(1024) + $image[0] = 0x4D + $image[1] = 0x5A + $image[0x3C] = 0x80 + $image[0x80] = 0x50 + $image[0x81] = 0x45 + $image[0x86] = 1 + $image[0x94] = 0xF0 + $image[0x96] = 0x22 + $image[0x98] = 0x0B + $image[0x99] = 0x02 + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0x9C) + [BitConverter]::GetBytes([uint32]4096).CopyTo($image, 0xA8) + [BitConverter]::GetBytes([uint32]4096).CopyTo($image, 0xAC) + [BitConverter]::GetBytes([uint64]0x140000000).CopyTo($image, 0xB0) + [BitConverter]::GetBytes([uint32]4096).CopyTo($image, 0xB8) + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0xBC) + $image[0xC0] = 6 + $image[0xC8] = 6 + [BitConverter]::GetBytes([uint32]8192).CopyTo($image, 0xD0) + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0xD4) + $image[0xDC] = 3 + [BitConverter]::GetBytes([uint64]1MB).CopyTo($image, 0xE0) + [BitConverter]::GetBytes([uint64]4096).CopyTo($image, 0xE8) + [BitConverter]::GetBytes([uint64]1MB).CopyTo($image, 0xF0) + [BitConverter]::GetBytes([uint64]4096).CopyTo($image, 0xF8) + $image[0x104] = 16 + [Text.Encoding]::ASCII.GetBytes(".text").CopyTo($image, 0x188) + [BitConverter]::GetBytes([uint32]4).CopyTo($image, 0x190) + [BitConverter]::GetBytes([uint32]4096).CopyTo($image, 0x194) + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0x198) + [BitConverter]::GetBytes([uint32]512).CopyTo($image, 0x19C) + [BitConverter]::GetBytes([uint32]0x60000020).CopyTo($image, 0x1AC) + $machine = if ($architecture -eq "arm64") { [uint16]0xAA64 } else { [uint16]0x8664 } + [BitConverter]::GetBytes($machine).CopyTo($image, 0x84) + foreach ($name in @("Devolutions.Terminal.exe", "dt.exe", "ghostty-vt.dll")) { + [IO.File]::WriteAllBytes((Join-Path $layout $name), $image) + } + foreach ($name in @("Devolutions.Terminal.ShellExt.dll", "dt-shell-integration.exe")) { + [IO.File]::WriteAllBytes((Join-Path $native $name), $image) + } + [IO.File]::WriteAllText((Join-Path $layout "THIRD-PARTY-NOTICES-GHOSTTY.txt"), "Fixture license") + } +} + +try { + foreach ($case in @( + @{ Name = "Development"; Publisher = "CN=Devolutions Inc."; Override = $false }, + @{ Name = "Production"; Publisher = $productionPublisher; Override = $true }, + @{ Name = "XmlEscaping"; Publisher = "CN=Fixture & Company"; Override = $true } + )) { + $output = Join-Path $work $case.Name + New-PackageFixture -OutputDirectory $output + $buildArguments = @{ + OutputDirectory = $output + SkipPublish = $true + SkipNativeBuild = $true + Version = "2026.3.0.0" + } + if ($case.Override) { + $buildArguments.Publisher = $case.Publisher + } + & (Join-Path $PSScriptRoot "Build-Packages.ps1") @buildArguments | Out-Host + # First-run WinApp setup can write status messages to the success stream. + $packages = @(Get-ChildItem -LiteralPath (Join-Path $output "packages") -File -Filter "*.msix") + if ($packages.Count -ne 2) { + throw "$($case.Name) build did not produce both architecture packages." + } + $generatedManifest = Join-Path $output "metadata\Package.appxmanifest" + [xml] $manifest = Get-Content -LiteralPath $generatedManifest -Raw -Encoding utf8 + if ($manifest.Package.Identity.Publisher -cne $case.Publisher -or + $manifest.Package.Identity.Version -cne "2026.3.0.0") { + throw "$($case.Name) build did not write the exact publisher and version." + } + $testArguments = @{ PackagePath = @($packages.FullName) } + if ($case.Override) { + $testArguments.ExpectedPublisher = $case.Publisher + } + $results = @(& (Join-Path $PSScriptRoot "Test-Packages.ps1") @testArguments) + if ($results.Count -ne 2 -or + ($results.Architecture | Sort-Object) -join "|" -cne "arm64|x64" -or + @($results | Where-Object { $_.Version -cne "2026.3.0.0" -or $_.Signed }).Count -ne 0) { + throw "$($case.Name) packages did not pass exact unsigned architecture/version validation." + } + Write-Host "Build-Packages_$($case.Name)PublisherRoundTripsBothArchitectures passed." + + if ($case.Override) { + Assert-ExpectedFailure -Name "Test-Packages_$($case.Name)RejectsDevelopmentPublisher" ` + -MessagePattern "Unexpected package publisher" -Action { + & (Join-Path $PSScriptRoot "Test-Packages.ps1") -PackagePath $packages[0].FullName + } + } + if ($case.Name -eq "XmlEscaping") { + continue + } + + $certificateDirectory = Join-Path $output "certificates" + $certificatePath = Join-Path $certificateDirectory "Devolutions.Terminal.pfx" + if ($case.Name -eq "Development") { + Copy-Item -LiteralPath $packages[0].FullName -Destination $wrongPublisherPackage + & (Join-Path $PSScriptRoot "New-DevelopmentCertificate.ps1") ` + -OutputDirectory $certificateDirectory -Password $password | Out-Null + } + else { + New-Item -ItemType Directory -Path $certificateDirectory | Out-Null + winapp cert generate --manifest $generatedManifest --output $certificatePath ` + --password "ephemeral-fixture-password" --valid-days 1 --quiet + if ($LASTEXITCODE -ne 0) { + throw "Production-subject fixture certificate generation failed." + } + $mismatchOutput = & winapp sign $wrongPublisherPackage $certificatePath ` + --password "ephemeral-fixture-password" --quiet 2>&1 + if ($LASTEXITCODE -eq 0 -or ($mismatchOutput -join "`n") -notmatch '0x8007000B') { + throw "Native signing did not reject the development publisher with the production-subject fixture certificate: $mismatchOutput" + } + Write-Host "Build-Packages_OldPublisherReproducesNativeSigningFailure passed." + } + $buildArguments.CertificatePath = $certificatePath + $buildArguments.CertificatePassword = $password + & (Join-Path $PSScriptRoot "Build-Packages.ps1") @buildArguments | Out-Host + $signedPackages = @(Get-ChildItem -LiteralPath (Join-Path $output "packages") -File -Filter "*.msix") + $testArguments.PackagePath = @($signedPackages.FullName) + $testArguments.RequireSignature = $true + $testArguments.AllowUntrustedRoot = $true + $signedResults = @(& (Join-Path $PSScriptRoot "Test-Packages.ps1") @testArguments) + if ($signedResults.Count -ne 2 -or @($signedResults | Where-Object { -not $_.Signed }).Count -ne 0) { + throw "$($case.Name) packages did not pass local signature and publisher validation." + } + Write-Host "Build-Packages_$($case.Name)LocalCertificateMatchesPublisher passed." + } + + foreach ($invalid in @("", " ", "CN=Fixture`nO=Company")) { + Assert-ExpectedFailure -Name "Build-Packages_RejectsInvalidPublisher" -MessagePattern "Publisher" -Action { + & (Join-Path $PSScriptRoot "Build-Packages.ps1") -Publisher $invalid ` + -OutputDirectory (Join-Path $work "invalid") -SkipPublish -SkipNativeBuild + } + } + if ((Get-FileHash -LiteralPath $sourceManifest).Hash -cne $sourceHash) { + throw "Package build tests changed the checked-in development manifest." + } + Write-Host "Build-Packages_SourceDevelopmentIdentityUnchanged passed." +} +finally { + if (Test-Path -LiteralPath $work) { + Remove-Item -LiteralPath $work -Recurse -Force + } +} diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 index 250390f..f7849bc 100644 --- a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 +++ b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 @@ -11,6 +11,10 @@ param( # Useful right after binaries are signed but before the final MSI container is signed. [switch] $RequireBinarySignature, + [ValidateNotNullOrEmpty()] + [ValidateScript({ -not [string]::IsNullOrWhiteSpace($_) -and $_ -notmatch '[\r\n]' })] + [string] $ExpectedPublisher = "CN=Devolutions Inc.", + [switch] $AllowUntrustedRoot ) @@ -88,7 +92,7 @@ begin { Assert-Condition ($null -ne $signature.SignerCertificate) "No signature was found on '$Path'." if ($RequirePublisherSubject) { Assert-Condition ( - $signature.SignerCertificate.Subject -eq "CN=Devolutions Inc." + $signature.SignerCertificate.Subject -ceq $ExpectedPublisher ) "The signer for '$Path' does not match the package publisher." } @@ -140,7 +144,7 @@ begin { $identity = $manifest.SelectSingleNode("/f:Package/f:Identity", $namespace) Assert-Condition ($identity.Name -eq "Devolutions.Terminal") "Unexpected package identity name." - Assert-Condition ($identity.Publisher -eq "CN=Devolutions Inc.") "Unexpected package publisher." + Assert-Condition ($identity.Publisher -ceq $ExpectedPublisher) "Unexpected package publisher '$($identity.Publisher)'; expected '$ExpectedPublisher'." Assert-Condition ($identity.ProcessorArchitecture -in @("x64", "arm64")) "Unexpected package architecture '$($identity.ProcessorArchitecture)'." $expectedMachine = if ($identity.ProcessorArchitecture -eq "arm64") { 0xAA64 } else { 0x8664 } $ghosttyMachine = Get-PeMachine $ghosttyPath