diff --git a/.github/actions/windows-signing/action.yml b/.github/actions/windows-signing/action.yml new file mode 100644 index 0000000..ea7bdd8 --- /dev/null +++ b/.github/actions/windows-signing/action.yml @@ -0,0 +1,70 @@ +name: Windows signing verification +description: Prepare explicit owned-file catalogs and verify final Authenticode signatures and preserved binary bytes. +inputs: + operation: + description: prepare, verify, or verify-distribution. + required: true + root: + description: Root directory for the files to sign. + default: . + signing-files: + description: Newline-separated required file names or relative path patterns. No blanket dependency signing. + default: "" + preserve-other-binaries: + description: Record and check binaries outside the signing catalog. + default: "false" + expected-subject: + description: Verified certificate subject read from the deployed Azure profile. + default: "" + manifest: + description: Manifest created by prepare; empty uses RUNNER_TEMP/k22-signing-manifest.json. + default: "" + directory: + description: Directory of final packaged assets to verify. + default: "" +outputs: + catalog: + description: File catalog consumed by azure/artifact-signing-action. + value: ${{ steps.run.outputs.catalog }} + manifest: + description: Owned files and third-party preservation manifest. + value: ${{ steps.run.outputs.manifest }} +runs: + using: composite + steps: + - id: run + shell: pwsh + env: + K22_SIGNING_OPERATION: ${{ inputs.operation }} + K22_SIGNING_ROOT: ${{ inputs.root }} + K22_SIGNING_FILES: ${{ inputs.signing-files }} + K22_SIGNING_PRESERVE: ${{ inputs.preserve-other-binaries }} + K22_SIGNING_SUBJECT: ${{ inputs.expected-subject }} + K22_SIGNING_MANIFEST: ${{ inputs.manifest }} + K22_SIGNING_DIRECTORY: ${{ inputs.directory }} + run: | + Set-StrictMode -Version Latest + $ErrorActionPreference = 'Stop' + Import-Module (Join-Path $env:GITHUB_ACTION_PATH '../../../tools/WindowsSigning.psm1') -Force + $manifest = $env:K22_SIGNING_MANIFEST + if (-not $manifest) { $manifest = Join-Path $env:RUNNER_TEMP 'k22-signing-manifest.json' } + $catalog = Join-Path $env:RUNNER_TEMP 'k22-signing-catalog.txt' + switch ($env:K22_SIGNING_OPERATION) + { + 'prepare' { + $patterns = @($env:K22_SIGNING_FILES -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + if ($patterns.Count -eq 0) { throw 'Explicit owned signing-files are required.' } + if ([string]::IsNullOrWhiteSpace($env:K22_SIGNING_SUBJECT)) { throw 'The verified publisher subject is required before selecting signing files.' } + New-WindowsSigningCatalog -Root $env:K22_SIGNING_ROOT -Patterns $patterns -CatalogPath $catalog -ManifestPath $manifest -ExpectedSubject $env:K22_SIGNING_SUBJECT -PreserveOtherBinaries:($env:K22_SIGNING_PRESERVE -eq 'true') + } + 'verify' { + Assert-WindowsSigningManifest -ManifestPath $manifest -ExpectedSubject $env:K22_SIGNING_SUBJECT + } + 'verify-distribution' { + Assert-WindowsSigningManifest -ManifestPath $manifest -ExpectedSubject $env:K22_SIGNING_SUBJECT + Assert-WindowsDistribution -Directory $env:K22_SIGNING_DIRECTORY -ManifestPath $manifest -ExpectedSubject $env:K22_SIGNING_SUBJECT + } + default { throw "Unsupported signing operation: $env:K22_SIGNING_OPERATION." } + } + "catalog=$catalog" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + "manifest=$manifest" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44e5393..86b976f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,9 +112,38 @@ jobs: shell: pwsh run: ./tools/Test-RepoConformance.ps1 -LocalPath . -StaticOnly + windows-signing: + name: windows-signing + runs-on: windows-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Lint and test Windows signing gates + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force + Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 + Import-Module Pester -RequiredVersion 5.7.1 + foreach ($path in @('tools/WindowsSigning.psm1', 'tools/WindowsSigning.Tests.ps1', 'tools/VelopackSigning.Tests.ps1')) + { + $findings = @(Invoke-ScriptAnalyzer -Path $path -Settings configs/shell/PSScriptAnalyzerSettings.psd1) + if ($findings.Count -gt 0) { $findings | Format-Table; throw "Script analysis failed: $path" } + } + $configuration = New-PesterConfiguration + $configuration.Run.Path = @('tools/WindowsSigning.Tests.ps1', 'tools/VelopackSigning.Tests.ps1') + $configuration.Run.Exit = $true + Invoke-Pester -Configuration $configuration + release-please: name: release-please - needs: [security, codeql, docs, conformance] + needs: [security, codeql, docs, conformance, windows-signing] if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.RELEASE_APP_CLIENT_ID != '' runs-on: ubuntu-24.04 timeout-minutes: 10 diff --git a/.github/workflows/release-nuget.yml b/.github/workflows/release-nuget.yml index f9bf390..667c9a2 100644 --- a/.github/workflows/release-nuget.yml +++ b/.github/workflows/release-nuget.yml @@ -64,6 +64,14 @@ on: description: Newline-separated test projects to run with coverage before packing. type: string default: "" + sign: + description: Authenticode-sign owned Windows binaries before packing, using Azure OIDC in the protected release environment. + type: boolean + default: false + signing-files: + description: Newline-separated required owned binary paths relative to the checkout; required when sign is true. + type: string + default: "" outputs: version: description: The released version (tag without the leading v). @@ -79,8 +87,10 @@ jobs: name: build runs-on: ${{ inputs.runs-on }} timeout-minutes: 60 + environment: release permissions: contents: read + id-token: write # Azure OIDC is available only in the protected release environment. outputs: version: ${{ steps.version.outputs.version }} prerelease: ${{ steps.version.outputs.prerelease }} @@ -186,12 +196,86 @@ jobs: } if ($failed) { exit 1 } + - name: Prepare owned binary signing catalog + if: inputs.sign + id: signing + uses: KofTwentyTwo/standards/.github/actions/windows-signing@3334d02b907d44f4dca1b32553ad2bd6203c7b7f # unreleased signing helpers + with: + operation: prepare + signing-files: ${{ inputs.signing-files }} + expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }} + + - name: Require verified Azure signing configuration + if: inputs.sign + shell: pwsh + env: + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }} + ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }} + ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }} + ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }} + run: | + foreach ($name in @('AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_SUBSCRIPTION_ID', 'ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT', 'ARTIFACT_SIGNING_PROFILE', 'ARTIFACT_SIGNING_SUBJECT')) + { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { throw "Missing verified signing variable: $name" } + } + + - name: Azure login (OIDC) + if: inputs.sign + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} + + - name: Sign owned binaries before packing + if: inputs.sign + uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + endpoint: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }} + signing-account-name: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }} + files-catalog: ${{ steps.signing.outputs.catalog }} + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + cache-dependencies: false + exclude-environment-credential: true + exclude-workload-identity-credential: true + exclude-managed-identity-credential: true + exclude-shared-token-cache-credential: true + exclude-visual-studio-credential: true + exclude-visual-studio-code-credential: true + exclude-azure-cli-credential: false + exclude-azure-powershell-credential: true + exclude-azure-developer-cli-credential: true + exclude-interactive-browser-credential: true + + - name: Verify signed binaries + if: inputs.sign + uses: KofTwentyTwo/standards/.github/actions/windows-signing@3334d02b907d44f4dca1b32553ad2bd6203c7b7f # unreleased signing helpers + with: + operation: verify + manifest: ${{ steps.signing.outputs.manifest }} + expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }} + - name: Pack shell: pwsh env: K22_VERSION: ${{ steps.version.outputs.version }} run: dotnet pack $env:K22_SOLUTION -c Release "-p:Platform=$env:K22_PLATFORM" "-p:Version=$env:K22_VERSION" --no-build -o artifacts/nuget + - name: Verify the exact binaries shipped in NuGet packages + if: inputs.sign + uses: KofTwentyTwo/standards/.github/actions/windows-signing@3334d02b907d44f4dca1b32553ad2bd6203c7b7f # unreleased signing helpers + with: + operation: verify-distribution + directory: artifacts/nuget + manifest: ${{ steps.signing.outputs.manifest }} + expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }} + # Maps each package to the project folder whose packages.lock.json describes its # dependencies, for the SBOM job. Package ids come from the produced file names. - name: List packages diff --git a/.github/workflows/release-velopack.yml b/.github/workflows/release-velopack.yml index 52ba0fe..0036391 100644 --- a/.github/workflows/release-velopack.yml +++ b/.github/workflows/release-velopack.yml @@ -26,7 +26,7 @@ # app unpackaged and self-contained, and the optional CLI as a single file. # package (Windows, environment `release`): optionally Authenticode-sign the # published binaries with Azure Trusted Signing over OIDC (input `sign`, -# off until exception EX-0002 closes), download the previous release on the +# enabled after repository signing verification), download the previous release on the # same channel as the delta baseline, `vpk pack` (which signs Setup.exe with # the same identity when `sign` is on), and zip the CLI. Only this build's # Velopack assets (from assets..json) are kept, with a @@ -100,9 +100,14 @@ on: Authenticode-sign binaries and Setup.exe with Azure Trusted Signing (OIDC). Requires the variables AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, TRUSTED_SIGNING_ENDPOINT, TRUSTED_SIGNING_ACCOUNT, and TRUSTED_SIGNING_PROFILE in the caller's `release` environment. - Off until exception EX-0002 (signing identity validation) closes. + ARTIFACT_SIGNING_SUBJECT must contain the verified publisher subject. Legacy TRUSTED_SIGNING_* + names remain accepted; ARTIFACT_SIGNING_* names take precedence. type: boolean default: false + signing-files: + description: Newline-separated required owned file names or publish-relative patterns, including the optional CLI; required when sign is true. + type: string + default: "" outputs: version: description: The released version (tag without the leading v). @@ -325,9 +330,34 @@ jobs: name: publish-cli path: publish/cli - # Signing is OFF by default: exception EX-0002 records that the Azure Trusted - # Signing identity is still being validated. Until then, integrity and origin rest - # on SHA256SUMS and the build-provenance attestations (K22-REL-04). + - name: Prepare owned binary signing catalog + if: inputs.sign + id: signing + uses: KofTwentyTwo/standards/.github/actions/windows-signing@3334d02b907d44f4dca1b32553ad2bd6203c7b7f # unreleased signing helpers + with: + operation: prepare + root: publish + signing-files: ${{ inputs.signing-files }} + expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }} + preserve-other-binaries: "true" + + - name: Require verified Azure signing configuration + if: inputs.sign + shell: pwsh + env: + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }} + ARTIFACT_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }} + ARTIFACT_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }} + ARTIFACT_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }} + ARTIFACT_SIGNING_SUBJECT: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }} + run: | + foreach ($name in @('AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_SUBSCRIPTION_ID', 'ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT', 'ARTIFACT_SIGNING_PROFILE', 'ARTIFACT_SIGNING_SUBJECT')) + { + if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) { throw "Missing verified signing variable: $name" } + } + - name: Azure login (OIDC) if: inputs.sign uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 @@ -336,37 +366,17 @@ jobs: tenant-id: ${{ vars.AZURE_TENANT_ID }} subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} - # Signs the executables and DLLs under publish that carry no valid signature - # yet (the app's own binaries and unsigned third-party ones), so the binaries - # inside the installer, the portable zip and the update packages are all - # signed (K22-REL-05). Already-signed files (the .NET runtime, Windows App SDK) - # keep their publisher's signature and are not re-signed: a self-contained - # publish holds ~300 of them at ~2 s each, which is how the first real run - # (gclo v1.0.2-beta.2, #26) hit the signing action's 300 s default timeout. - - name: Select the binaries to sign - if: inputs.sign - id: sign-list - shell: pwsh - env: - K22_MAIN_EXE: ${{ inputs.main-exe }} - K22_CLI_PROJECT: ${{ inputs.cli-project }} - run: | - $all = Get-ChildItem publish -Recurse -Include *.exe, *.dll - $unsigned = @($all | Where-Object { (Get-AuthenticodeSignature -LiteralPath $_.FullName).Status -ne 'Valid' }) - $names = $unsigned | ForEach-Object { $_.Name } - if ($names -notcontains $env:K22_MAIN_EXE) { throw "publish/app/$env:K22_MAIN_EXE is missing or already signed by someone else." } - if ($env:K22_CLI_PROJECT -and -not ($unsigned | Where-Object { $_.Extension -eq '.exe' -and $_.FullName -like '*\publish\cli\*' })) { throw 'publish/cli has no unsigned exe to sign.' } - Write-Output "Signing $($unsigned.Count) of $($all.Count) binaries; $($all.Count - $unsigned.Count) already carry a valid signature." - "files<.json, plus a releases..json feed trimmed to this build's @@ -491,6 +508,32 @@ jobs: shell: pwsh run: Compress-Archive -Path publish/cli/* -DestinationPath "release/$env:K22_APP_ID-cli-$env:K22_RUNTIME-$env:K22_VERSION.zip" + - name: Verify final installer, portable, full update and CLI assets + if: inputs.sign + uses: KofTwentyTwo/standards/.github/actions/windows-signing@3334d02b907d44f4dca1b32553ad2bd6203c7b7f # unreleased signing helpers + with: + operation: verify-distribution + directory: release + manifest: ${{ steps.signing.outputs.manifest }} + expected-subject: ${{ vars.ARTIFACT_SIGNING_SUBJECT || vars.TRUSTED_SIGNING_SUBJECT }} + + # Verification precedes upload, SBOM attestations, hashes and provenance. + - name: Upload signed app output for its SBOM + if: inputs.sign + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: signed-publish-app + path: publish/app/ + if-no-files-found: error + + - name: Upload signed CLI output for its SBOM + if: inputs.sign && inputs.cli-project != '' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: signed-publish-cli + path: publish/cli/ + if-no-files-found: error + - name: Upload release files uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -514,14 +557,14 @@ jobs: - name: Download published app uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: publish-app + name: ${{ inputs.sign && 'signed-publish-app' || 'publish-app' }} path: publish/app - name: Download published CLI if: inputs.cli-project != '' uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: publish-cli + name: ${{ inputs.sign && 'signed-publish-cli' || 'publish-cli' }} path: publish/cli - name: Download release files diff --git a/architecture/windows-release-signing.md b/architecture/windows-release-signing.md new file mode 100644 index 0000000..f306c60 --- /dev/null +++ b/architecture/windows-release-signing.md @@ -0,0 +1,94 @@ +# Windows release signing + +The reusable NuGet and Velopack builders support Azure Artifact Signing over GitHub +OIDC. Reuse an existing validated signing account and active PublicTrust profile; +retrieve the actual tenant GUID, regional account URI, profile name and certificate +subject from Azure before setting variables. Account/domain names and subscription +IDs are not tenant GUIDs. Do not export a private key or use an administrator's +password in CI. + +## Trust and configuration + +Configure a reviewed Entra application/service principal per repository, or reuse a +suitable existing CI identity while preserving other repositories' credentials. +Use the issuer `https://token.actions.githubusercontent.com`, audience +`api://AzureADTokenExchange` and subject `repo:OWNER/REPOSITORY:environment:release`. +Protect the `release` environment against untrusted refs. Assign the built-in +**Artifact Signing Certificate Profile Signer** at the existing certificate profile +scope; Azure Owner/Contributor is not a signing permission. + +Set these environment variables using verified deployed values: + +| Variable | Value | +| --- | --- | +| `AZURE_TENANT_ID` | Actual tenant GUID | +| `AZURE_CLIENT_ID` | CI application client ID | +| `AZURE_SUBSCRIPTION_ID` | Signing subscription ID | +| `ARTIFACT_SIGNING_ENDPOINT` | Account's regional HTTPS URI | +| `ARTIFACT_SIGNING_ACCOUNT` | Existing account resource name | +| `ARTIFACT_SIGNING_PROFILE` | Existing active profile name | +| `ARTIFACT_SIGNING_SUBJECT` | Profile certificate's verified subject | + +Callers can retain legacy `TRUSTED_SIGNING_ENDPOINT`, `_ACCOUNT`, `_PROFILE` and +`_SUBJECT` names; the new names take precedence. The verified subject is required. +Both builders use official full-SHA-pinned `azure/login` v3 and +`azure/artifact-signing-action` v2, Windows runners and `AzureCliCredential` only. +Signing jobs run in `release` with `contents: read` and `id-token: write`. No +pull-request signing path exists. SHA256 file digests and RFC3161 SHA256 timestamps +at `http://timestamp.acs.microsoft.com` are mandatory when signing is enabled. + +## Caller inputs and artifact order + +Set `sign: true` and a newline-separated `signing-files` list. Every pattern must +match; missing binaries fail rather than quietly omitting a signature. +Owned and preserved `.exe`/`.dll` headers must parse before a catalog is written; +hash preservation cannot prove an already corrupt source is executable. MSI/MSIX +use their package/signature validation paths rather than PE parsing. For NuGet, +use exact checkout-relative built DLL paths. For Velopack, use publish-relative +paths such as `app/MyApp.exe`, `app/MyApp.dll`, `app/MyApp.Core.dll` and +`cli/myapp.exe`. Include all owned modules. Exclude third-party dependencies. + +NuGet signs before `pack --no-build`, then checks the DLLs inside the final packages +against their signed originals. Velopack signs owned published binaries, preserves +other binary hashes, and uses vpk's signing hook only for its branded Squirrel and +execution stubs plus the final Setup/MSI. Final checks validate trust, expected +publisher, timestamps and SHA256 for the shipped binaries, require unchanged +dependency bytes, and verify Setup's embedded full package matches the verified +full update package. Culture-directory satellite assemblies retain their separate +paths. Verification precedes upload, SBOM attestations, checksums and provenance. + +Signing or verification failure stops publication when `sign` is true. The default +remains false for compatibility; each owner must enable required signing in their +caller or block publication during migration. NuGet/ZIP/delta containers are not +Authenticode targets. Test reconstructed delta updates on installed channel builds +before claiming upgrade coverage. MSIX presets must use the verified subject as +their manifest Publisher. + +## Local tools and proof + +Use Microsoft's [current integration instructions](https://learn.microsoft.com/azure/artifact-signing/how-to-signing-integrations) +for Artifact Signing Client Tools, matching-architecture SignTool and +`Azure.CodeSigning.Dlib.dll`, .NET 8 and the Visual C++ runtime. Metadata fields are +`Endpoint`, `CodeSigningAccountName` and `CertificateProfileName`. Authenticate with +an identity that has the profile signer role and sign with `/fd SHA256`, +`/tr http://timestamp.acs.microsoft.com`, `/td SHA256`, `/dlib` and `/dmdf`. + +Run `signtool verify /pa /all /v` on final installers and extracted shipped binaries. +Record publisher, trust, timestamp, exact artifact hashes and the CI run. An active +profile, a mocked test or a signed Microsoft tool does not prove an application's +signing works. Retain any signing exception until real end-to-end evidence exists. + +## Prepared rollout + +This implementation is prepared against published standards v1.0.0. Preserve that +release's metadata, policy changes and signing evidence. Its released Velopack +path lacks the explicit owned-file catalog and its NuGet path lacks these signing +inputs, so it is not sufficient for an AppKit caller that requires them. + +The prepared workflows reference helper snapshot `4a7f028976163cb80730126eda1b029005803325`, +which includes source PE parsing and final missing-dependency checks. These references remain +unreleased placeholders until the reviewed helper commit is available on GitHub +and the completed builder is published with its proper version comment. Verify +that exact helper revision, not an older catalog implementation. Keep consumer +publication blocked until the reviewed builder is published, pinned and proven +with representative signed artifacts. Local tests do not authorize rollout. diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 21636ad..715d8d2 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -71,6 +71,18 @@ trust boundaries change. ## Review log +- **Prepared AppKit signing extension, 2026-10-10:** Explicit owned-file selection + excludes third-party binaries from re-signing, retains their hashes and requires + their presence in final archives. Catalog preparation parses owned/preserved + `.exe`/`.dll` headers before writes; already corrupt dependencies cannot pass + solely because hashes are unchanged. Final owned binaries and installers require + publisher, trust, SHA256 and timestamp checks before upload or publication. The + SignTool packaging wrapper preserves valid signatures and refuses invalid ones. + NuGet signs before packing and checks embedded DLL bytes. Existing protected + release-environment OIDC boundaries remain; real AppKit identity/profile access + and signatures are still unproved. The helper reference and consumer rollout + remain pending review/publication; this entry is not deployment evidence. + - **2026-10-10, v1.0.0:** Reviewed changes since v0.1.2: mandatory applicable badge participation and parser fuzzing, completed passing self-assessment, professional release text, scoped frontend audit exception, and unsigned-file-only Windows diff --git a/templates/workflows/release-nuget.yml b/templates/workflows/release-nuget.yml index 6d50ef4..d795134 100644 --- a/templates/workflows/release-nuget.yml +++ b/templates/workflows/release-nuget.yml @@ -35,6 +35,10 @@ jobs: coverage-packages: "{{COVERAGE_PACKAGES}}" test-projects: | {{TEST_PROJECT}} + # For Windows libraries, enable only after verified Azure OIDC configuration: + # sign: true + # signing-files: | + # src/My.Library/bin/Release/net10.0/My.Library.dll publish: name: publish (nuget.org) diff --git a/templates/workflows/release-velopack.yml b/templates/workflows/release-velopack.yml index 93004ac..a41d24b 100644 --- a/templates/workflows/release-velopack.yml +++ b/templates/workflows/release-velopack.yml @@ -8,11 +8,12 @@ # One-time owner setup: # - Environment `release` (Settings -> Environments) with a deployment rule that allows # only tags matching v*. -# - Code signing (when exception EX-0002 closes): add the environment variables -# AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, TRUSTED_SIGNING_ENDPOINT, -# TRUSTED_SIGNING_ACCOUNT, TRUSTED_SIGNING_PROFILE (a federated credential for this -# repository's `release` environment on the Azure app registration), then set -# `sign: true` below. +# - Code signing: verify the existing Artifact Signing profile, configure repository +# OIDC and its Certificate Profile Signer role, then add AZURE_CLIENT_ID, +# AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, ARTIFACT_SIGNING_ENDPOINT, +# ARTIFACT_SIGNING_ACCOUNT, ARTIFACT_SIGNING_PROFILE and ARTIFACT_SIGNING_SUBJECT +# to the protected release environment. Enable sign and enumerate owned files. +# See architecture/windows-release-signing.md; an Active profile alone is not proof. # - winget (optional, stable releases only): the package must already exist in # microsoft/winget-pkgs. Submit it once by hand with # wingetcreate new @@ -51,7 +52,13 @@ jobs: test-projects: | {{TEST_PROJECT}} cli-project: "{{CLI_PROJECT}}" # remove for apps without a CLI - sign: false # exception EX-0002: true once the Azure Trusted Signing identity is validated + sign: false # enable only after CI authentication and real signed-artifact verification + # Required when sign is true; include all owned app/CLI binaries, not dependencies: + # signing-files: | + # app/MyApp.exe + # app/MyApp.dll + # app/MyApp.Core.dll + # cli/myapp.exe # winget lives here, not in the shared workflow, because its token belongs to this # repository's `release` environment. It runs only for stable releases, and only when diff --git a/tools/VelopackSigning.Tests.ps1 b/tools/VelopackSigning.Tests.ps1 new file mode 100644 index 0000000..fefa988 --- /dev/null +++ b/tools/VelopackSigning.Tests.ps1 @@ -0,0 +1,84 @@ +#Requires -Version 7.4 +# Copyright (c) 2026 James Maes +# SPDX-License-Identifier: MIT + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +BeforeAll ` +{ + $lines = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../.github/workflows/release-velopack.yml') + $start = [Array]::IndexOf($lines, " @'") + $end = $start + 1 + while($end -lt $lines.Count -and -not $lines[$end].StartsWith(" '@ | Set-Content")) { $end++ } + if($start -lt 0 -or $end -ge $lines.Count) { throw 'Cannot locate the actual Velopack signing wrapper.' } + $script:Wrapper = Join-Path $TestDrive 'sign-one.ps1' + [IO.File]::WriteAllLines($script:Wrapper, @($lines[($start + 1)..($end - 1)] | ForEach-Object { $_.Substring(10) })) + $script:OldTool = $env:K22_VPK_SIGN_TOOL + $env:K22_VPK_SIGN_TOOL = 'Invoke-WrapperTool' + + <# + .SYNOPSIS + Simulates signing and verification without using credentials. + #> + function Invoke-WrapperTool + { + [CmdletBinding()] + param([Parameter(ValueFromRemainingArguments)] [string[]] $ToolArguments) + + $ToolCalls.Add($ToolArguments) + $global:LASTEXITCODE = if($ToolArguments[0] -eq 'sign') { $SignExit } else { $VerifyExit } + } +} + +AfterAll ` +{ + $env:K22_VPK_SIGN_TOOL = $script:OldTool +} + +Describe 'The actual Velopack signing wrapper' ` +{ + BeforeEach ` + { + $script:ToolCalls = [Collections.Generic.List[object]]::new() + $script:SignExit = 0 + $script:VerifyExit = 0 + Mock Get-AuthenticodeSignature { [pscustomobject]@{ Status = 'NotSigned' } } + } + + It 'preserves a valid existing signature without signing again' ` + { + Mock Get-AuthenticodeSignature { [pscustomobject]@{ Status = 'Valid' } } + & $script:Wrapper -File 'a file.dll' + $script:ToolCalls.Count | Should -Be 0 + } + + It 'refuses an invalid existing signature' ` + { + Mock Get-AuthenticodeSignature { [pscustomobject]@{ Status = 'HashMismatch' } } + { & $script:Wrapper -File 'a file.dll' } | Should -Throw '*invalid signature*' + $script:ToolCalls.Count | Should -Be 0 + } + + It 'stops on signing failure before verification' ` + { + $script:SignExit = 1 + { & $script:Wrapper -File 'a file.dll' } | Should -Throw '*Artifact Signing failed*' + $script:ToolCalls.Count | Should -Be 1 + } + + It 'stops when signature verification fails' ` + { + $script:VerifyExit = 1 + { & $script:Wrapper -File 'a file.dll' } | Should -Throw '*Signature verification failed*' + $script:ToolCalls.Count | Should -Be 2 + } + + It 'uses SHA256, RFC3161 timestamping and all-signature trust verification' ` + { + & $script:Wrapper -File 'a file.dll' + $script:ToolCalls.Count | Should -Be 2 + ($script:ToolCalls[0] -join ' ') | Should -BeLike 'sign /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256* a file.dll' + ($script:ToolCalls[1] -join ' ') | Should -Be 'verify /pa /all /v a file.dll' + } +} diff --git a/tools/WindowsSigning.Tests.ps1 b/tools/WindowsSigning.Tests.ps1 new file mode 100644 index 0000000..9f46998 --- /dev/null +++ b/tools/WindowsSigning.Tests.ps1 @@ -0,0 +1,361 @@ +#Requires -Version 7.4 +# Copyright (c) 2026 James Maes (KofTwentyTwo) +# SPDX-License-Identifier: MIT + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +BeforeDiscovery ` +{ + Import-Module (Join-Path $PSScriptRoot 'WindowsSigning.psm1') -Force -Global +} + +Describe 'Owned binary catalog' ` +{ + BeforeEach ` + { + $script:Root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $script:Root | Out-Null + $script:Catalog = Join-Path $script:Root 'catalog.txt' + $script:Manifest = Join-Path $script:Root 'manifest.json' + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $script:Root 'Owned.dll') + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $script:Root 'Microsoft.dll') + } + + It 'includes only explicit owned names and inventories other binaries' ` + { + New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest -PreserveOtherBinaries + @(Get-Content -LiteralPath $script:Catalog) | Should -HaveCount 1 + Get-Content -LiteralPath $script:Catalog | Should -Be 'Owned.dll' + $entries = @(Get-Content -LiteralPath $script:Manifest -Raw | ConvertFrom-Json) + $entries | Should -HaveCount 2 + @($entries | Where-Object owned).relativePath | Should -Be 'Owned.dll' + @($entries | Where-Object { -not $_.owned }).relativePath | Should -Be 'Microsoft.dll' + } + + It 'rejects a missing required binary rather than silently skipping signing' ` + { + { New-WindowsSigningCatalog -Root $script:Root -Patterns @('Missing.exe') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | + Should -Throw '*No binary matches*' + } + + It 'rejects parent traversal' ` + { + { New-WindowsSigningCatalog -Root $script:Root -Patterns @('../Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | + Should -Throw '*relative paths*' + } + + It 'supports WhatIf without writing signing inputs' ` + { + New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest -WhatIf + Test-Path -LiteralPath $script:Catalog | Should -BeFalse + Test-Path -LiteralPath $script:Manifest | Should -BeFalse + } + + It 'rejects malformed owned PE input before writing a catalog' ` + { + [IO.File]::WriteAllBytes((Join-Path $script:Root 'Owned.dll'), [byte[]]::new(128)) + { New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | Should -Throw '*Invalid published PE binary: Owned.dll*' + Test-Path -LiteralPath $script:Catalog | Should -BeFalse + } + + It 'rejects an already malformed preserved dependency' ` + { + [IO.File]::WriteAllBytes((Join-Path $script:Root 'Microsoft.dll'), [byte[]]::new(128)) + { New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest -PreserveOtherBinaries } | Should -Throw '*Invalid published PE binary: Microsoft.dll*' + Test-Path -LiteralPath $script:Manifest | Should -BeFalse + } + + It 'does not treat package-format signing targets as PE images' ` + { + Set-Content -LiteralPath (Join-Path $script:Root 'Setup.msi') -Value 'package fixture; signature validation is a later gate' + { New-WindowsSigningCatalog -Root $script:Root -Patterns @('Setup.msi') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | Should -Not -Throw + Get-Content -LiteralPath $script:Catalog | Should -Be 'Setup.msi' + } + + It 'ignores excluded binaries when dependency preservation is disabled' ` + { + [IO.File]::WriteAllBytes((Join-Path $script:Root 'Microsoft.dll'), [byte[]]::new(128)) + { New-WindowsSigningCatalog -Root $script:Root -Patterns @('Owned.dll') -CatalogPath $script:Catalog -ManifestPath $script:Manifest } | Should -Not -Throw + @(Get-Content -LiteralPath $script:Manifest -Raw | ConvertFrom-Json) | Should -HaveCount 1 + } +} + +Describe 'Signature and distributed-byte gates' ` +{ + InModuleScope WindowsSigning ` + { + BeforeAll ` + { + <# + .SYNOPSIS + Supplies deterministic SignTool results for rejection-path tests. + #> + function Invoke-ProbeSignTool + { + $global:LASTEXITCODE = $script:ProbeExitCode + return $script:ProbeOutput + } + } + + BeforeEach ` + { + $script:ProbeExitCode = 0 + $script:ProbeOutput = 'Hash of file (sha256): 0123456789' + $script:ProbeSignature = [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ Subject = 'CN=Verified Publisher'; SubjectName = [pscustomobject]@{ Name = 'CN=Verified Publisher' } } + TimeStamperCertificate = [pscustomobject]@{ Subject = 'CN=Timestamp Authority' } + } + Mock Get-AuthenticodeSignature { return $script:ProbeSignature } + } + + It 'accepts a trusted timestamped SHA256 signature for the verified subject' ` + { + { Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw + } + + It 'rejects a native verification failure' ` + { + $script:ProbeExitCode = 1 + { Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*verification failed*' + } + + It 'rejects a different publisher' ` + { + { Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Other Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Unexpected publisher*' + } + + It 'rejects a missing timestamp' ` + { + $script:ProbeSignature.TimeStamperCertificate = $null + { Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*timestamped*' + } + + It 'rejects an untrusted certificate' ` + { + $script:ProbeSignature.Status = 'NotTrusted' + { Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*timestamped*' + } + + It 'rejects a non-SHA256 digest' ` + { + $script:ProbeOutput = 'Hash of file (sha1): 0123456789' + { Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*SHA256*' + } + + It 'rejects a SHA1 signature appended alongside a SHA256 signature' ` + { + $script:ProbeOutput = "Hash of file (sha256): abc`nHash of file (sha1): def" + { Assert-WindowsSignature -Files @('Owned.dll') -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*SHA256*' + } + + It 'rejects modified third-party binaries' ` + { + $root = Join-Path $TestDrive 'preserve' + New-Item -ItemType Directory -Path $root | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll') + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Microsoft.dll') + $manifest = Join-Path $root 'manifest.json' + New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries + Set-Content -LiteralPath (Join-Path $root 'Microsoft.dll') -Value 'modified' + { Assert-WindowsSigningManifest -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Third-party binary was modified*' + } + + It 'refuses to catalog another publisher signature for replacement' ` + { + $root = Join-Path $TestDrive 'other-publisher' + New-Item -ItemType Directory -Path $root | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll') + $script:ProbeSignature.SignerCertificate.SubjectName.Name = 'CN=Other Publisher' + { New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath (Join-Path $root 'manifest.json') -ExpectedSubject 'CN=Verified Publisher' } | Should -Throw '*another publisher*' + Test-Path -LiteralPath (Join-Path $root 'catalog.txt') | Should -BeFalse + } + + It 'rejects changed owned bytes in the final package' ` + { + $root = Join-Path $TestDrive 'changed' + $assets = Join-Path $root 'assets' + $packed = Join-Path $root 'packed' + New-Item -ItemType Directory -Path $root, $assets, $packed | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll') + $manifest = Join-Path $root 'manifest.json' + New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest + Set-Content -LiteralPath (Join-Path $packed 'Owned.dll') -Value 'modified after signing' + [IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned.1.0.0.nupkg')) + { Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Distributed binary changed*' + } + + It 'rejects an archive missing its required owned binary' ` + { + $root = Join-Path $TestDrive 'missing' + $assets = Join-Path $root 'assets' + $packed = Join-Path $root 'packed' + New-Item -ItemType Directory -Path $root, $assets, $packed | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll') + $manifest = Join-Path $root 'manifest.json' + New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest + Set-Content -LiteralPath (Join-Path $packed 'README.txt') -Value 'no binary' + [IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned.1.0.0.nupkg')) + { Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Owned binary missing*' + } + + It 'rejects a missing dependency from ' -TestCases @( + @{ Archive = 'Owned-Portable.zip'; Head = 'app' }, + @{ Archive = 'Owned-1.0.0-full.nupkg'; Head = 'app' }, + @{ Archive = 'Owned-cli-win-x64.zip'; Head = 'cli' } + ) ` + { + param($Archive, $Head) + $root = Join-Path $TestDrive ('missing-dependency-' + [guid]::NewGuid().ToString('N')) + $publish = Join-Path $root 'publish' + $assets = Join-Path $root 'assets' + $packed = Join-Path $root 'packed' + New-Item -ItemType Directory -Path "$publish/$Head", $assets, $packed | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/$Head/Owned.dll" + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/$Head/Microsoft.dll" + $manifest = Join-Path $root 'manifest.json' + New-WindowsSigningCatalog -Root $publish -Patterns @("$Head/Owned.dll") -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries + Copy-Item -LiteralPath "$publish/$Head/Owned.dll" -Destination $packed + [IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets $Archive)) + { Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Third-party binary missing*' + } + + It 'allows only the default excluded foreign runtime helpers to be omitted' ` + { + $root = Join-Path $TestDrive 'excluded-runtime' + $publish = Join-Path $root 'publish' + $assets = Join-Path $root 'assets' + $packed = Join-Path $root 'packed' + New-Item -ItemType Directory -Path "$publish/app", $assets, $packed | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.dll" + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Microsoft.dll" + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/createdump.exe" + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.vshost.exe" + $manifest = Join-Path $root 'manifest.json' + New-WindowsSigningCatalog -Root $publish -Patterns @('app/Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries + Copy-Item -LiteralPath "$publish/app/Owned.dll", "$publish/app/Microsoft.dll" -Destination $packed + [IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned-Portable.zip')) + { Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw + } + + It 'rejects changed dependency bytes in the final archive' ` + { + $root = Join-Path $TestDrive 'changed-dependency' + $publish = Join-Path $root 'publish' + $assets = Join-Path $root 'assets' + $packed = Join-Path $root 'packed' + New-Item -ItemType Directory -Path "$publish/app", $assets, $packed | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.dll" + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Microsoft.dll" + $manifest = Join-Path $root 'manifest.json' + New-WindowsSigningCatalog -Root $publish -Patterns @('app/Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries + Copy-Item -LiteralPath "$publish/app/Owned.dll" -Destination $packed + Set-Content -LiteralPath (Join-Path $packed 'Microsoft.dll') -Value 'changed' + [IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned-Portable.zip')) + { Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Throw '*Distributed binary changed*' + } + + It 'accepts the exact signed bytes shipped in a package' ` + { + $root = Join-Path $TestDrive 'valid' + $assets = Join-Path $root 'assets' + New-Item -ItemType Directory -Path $root, $assets | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll') + $manifest = Join-Path $root 'manifest.json' + New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest + $packed = Join-Path $root 'packed' + New-Item -ItemType Directory -Path $packed | Out-Null + Copy-Item -LiteralPath (Join-Path $root 'Owned.dll') -Destination $packed + [IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets 'Owned.1.0.0.nupkg')) + { Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw + } + + It 'verifies separate NuGet packages whose IDs share the core package prefix' ` + { + $root = Join-Path $TestDrive 'prefix' + $assets = Join-Path $root 'assets' + New-Item -ItemType Directory -Path $root, $assets | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.dll') + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination (Join-Path $root 'Owned.Adapter.dll') + $manifest = Join-Path $root 'manifest.json' + New-WindowsSigningCatalog -Root $root -Patterns @('Owned.dll', 'Owned.Adapter.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest + foreach($name in @('Owned', 'Owned.Adapter')) + { + $packed = Join-Path $root $name + New-Item -ItemType Directory -Path $packed | Out-Null + Copy-Item -LiteralPath (Join-Path $root "$name.dll") -Destination $packed + [IO.Compression.ZipFile]::CreateFromDirectory($packed, (Join-Path $assets "$name.1.0.0.nupkg")) + } + { Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw + } + + It 'preserves same-name third-party satellite binaries in different culture directories' ` + { + $root = Join-Path $TestDrive 'cultures' + $publish = Join-Path $root 'publish' + $assets = Join-Path $root 'assets' + New-Item -ItemType Directory -Path $assets, "$publish/app/en", "$publish/app/fr" | Out-Null + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/Owned.dll" + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/en/Library.resources.dll" + [IO.File]::AppendAllText("$publish/app/en/Library.resources.dll", 'English') + Copy-Item -LiteralPath ([Reflection.PortableExecutable.PEReader].Assembly.Location) -Destination "$publish/app/fr/Library.resources.dll" + [IO.File]::AppendAllText("$publish/app/fr/Library.resources.dll", 'French') + $manifest = Join-Path $root 'manifest.json' + New-WindowsSigningCatalog -Root $publish -Patterns @('app/Owned.dll') -CatalogPath (Join-Path $root 'catalog.txt') -ManifestPath $manifest -PreserveOtherBinaries + [IO.Compression.ZipFile]::CreateFromDirectory("$publish/app", (Join-Path $assets 'Owned-Portable.zip')) + { Assert-WindowsDistribution -Directory $assets -ManifestPath $manifest -ExpectedSubject 'CN=Verified Publisher' -SignTool Invoke-ProbeSignTool } | Should -Not -Throw + } + } +} + +Describe 'Velopack installer payload' ` +{ + BeforeEach ` + { + $root = Join-Path $TestDrive ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $root | Out-Null + $script:Installer = Join-Path $root 'Probe-Setup.exe' + $script:FullPackage = Join-Path $root 'Probe-full.nupkg' + $payload = [Text.Encoding]::UTF8.GetBytes('exact full package payload') + [IO.File]::WriteAllBytes($script:FullPackage, $payload) + Copy-Item -LiteralPath (Get-WindowsSignTool) -Destination $script:Installer + $stream = [IO.File]::Open($script:Installer, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite) + $reader = [Reflection.PortableExecutable.PEReader]::new($stream, [Reflection.PortableExecutable.PEStreamOptions]::LeaveOpen) + try + { + $headerPosition = $reader.PEHeaders.SectionHeaders[0].PointerToRawData + $packageOffset = $stream.Length + $stream.Position = $headerPosition + $stream.Write([BitConverter]::GetBytes($packageOffset)) + $stream.Write([BitConverter]::GetBytes([long] $payload.Length)) + $stream.Write([Convert]::FromHexString('94F0B17B6893E02937EB34EF53AAE7D42B54F5707EF5D6F57854983E5E94ED7D')) + $stream.Position = $packageOffset + $stream.Write($payload) + } + finally + { + $reader.Dispose() + $stream.Dispose() + } + } + + It 'accepts the exact embedded full-package bytes' ` + { + { Assert-VelopackInstallerPayload -Installer $script:Installer -FullPackage $script:FullPackage } | Should -Not -Throw + } + + It 'rejects a different embedded payload of the same length' ` + { + $bytes = [IO.File]::ReadAllBytes($script:FullPackage) + $bytes[0] = 0 + [IO.File]::WriteAllBytes($script:FullPackage, $bytes) + { Assert-VelopackInstallerPayload -Installer $script:Installer -FullPackage $script:FullPackage } | Should -Throw '*different full package*' + } + + It 'rejects a different full-package length' ` + { + [IO.File]::WriteAllText($script:FullPackage, 'short') + { Assert-VelopackInstallerPayload -Installer $script:Installer -FullPackage $script:FullPackage } | Should -Throw '*length differs*' + } +} diff --git a/tools/WindowsSigning.psm1 b/tools/WindowsSigning.psm1 new file mode 100644 index 0000000..4f070b5 --- /dev/null +++ b/tools/WindowsSigning.psm1 @@ -0,0 +1,337 @@ +#Requires -Version 7.4 +# Copyright (c) 2026 James Maes (KofTwentyTwo) +# SPDX-License-Identifier: MIT + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +<# +.SYNOPSIS + Resolves the newest installed x64 Windows SDK SignTool. +#> +function Get-WindowsSignTool +{ + [CmdletBinding()] + param() + + $sdk = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits/10/bin' + $tools = @(Get-ChildItem -Path "$sdk/*/x64/signtool.exe" -File -ErrorAction SilentlyContinue | + Sort-Object { [version] $_.Directory.Parent.Name } -Descending) + if($tools.Count -eq 0) { throw 'Install the Windows SDK with x64 SignTool.' } + return $tools[0].FullName +} + + + +<# +.SYNOPSIS + Builds an explicit signing catalog and records other binaries for preservation checks. +#> +function New-WindowsSigningCatalog +{ + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)] [string] $Root, + [Parameter(Mandatory)] [string[]] $Patterns, + [Parameter(Mandatory)] [string] $CatalogPath, + [Parameter(Mandatory)] [string] $ManifestPath, + [string] $ExpectedSubject = '', + [switch] $PreserveOtherBinaries + ) + + $rootPath = (Resolve-Path -LiteralPath $Root).Path + $binaries = @(Get-ChildItem -LiteralPath $rootPath -File -Recurse | + Where-Object Extension -In '.exe', '.dll', '.msi', '.msix') + $owned = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($pattern in $Patterns) + { + $pattern = $pattern.Trim().Replace('\', '/') + if([string]::IsNullOrWhiteSpace($pattern) -or $pattern.Split('/') -contains '..' -or [IO.Path]::IsPathRooted($pattern)) + { + throw "Signing patterns must be nonempty relative paths or file names: '$pattern'." + } + $catalogMatches = @($binaries | Where-Object { + $candidate = if($pattern.Contains('/')) { [IO.Path]::GetRelativePath($rootPath, $_.FullName).Replace('\', '/') } else { $_.Name } + $candidate -like $pattern + }) + if($catalogMatches.Count -eq 0) { throw "No binary matches required signing pattern '$pattern'." } + foreach($file in $catalogMatches) + { + if($ExpectedSubject) + { + $expected = [Security.Cryptography.X509Certificates.X500DistinguishedName]::new($ExpectedSubject).Name + $existing = Get-AuthenticodeSignature -LiteralPath $file.FullName + if($null -ne $existing.SignerCertificate -and $existing.SignerCertificate.SubjectName.Name -cne $expected) + { + throw "Refusing to replace another publisher's signature on '$($file.FullName)'. Keep it outside the owned catalog." + } + if($existing.Status -notin 'Valid', 'NotSigned') { throw "Invalid existing signature on '$($file.FullName)'." } + } + $null = $owned.Add($file.FullName) + } + } + foreach($file in $binaries | Where-Object { ($PreserveOtherBinaries -or $owned.Contains($_.FullName)) -and $_.Extension -in '.exe', '.dll' }) + { + $stream = [IO.File]::OpenRead($file.FullName) + try + { + $reader = [Reflection.PortableExecutable.PEReader]::new($stream) + try + { + if($null -eq $reader.PEHeaders.PEHeader) { throw 'Missing executable header.' } + } + finally { $reader.Dispose() } + } + catch { throw "Invalid published PE binary: $($file.Name). $($_.Exception.Message)" } + finally { $stream.Dispose() } + } + $entries = @($binaries | Where-Object { $PreserveOtherBinaries -or $owned.Contains($_.FullName) } | ForEach-Object { + [ordered]@{ + path = $_.FullName + relativePath = [IO.Path]::GetRelativePath($rootPath, $_.FullName).Replace('\', '/') + owned = $owned.Contains($_.FullName) + sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } + }) + $catalogDirectory = [IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($CatalogPath)) + if(-not $PSCmdlet.ShouldProcess("$CatalogPath; $ManifestPath", 'Write signing catalog and preservation manifest')) { return } + New-Item -ItemType Directory -Force -Path $catalogDirectory | Out-Null + @($owned | Sort-Object | ForEach-Object { [IO.Path]::GetRelativePath($catalogDirectory, $_) }) | + Set-Content -LiteralPath $CatalogPath -Encoding utf8NoBOM + ConvertTo-Json -InputObject $entries -Depth 5 | Set-Content -LiteralPath $ManifestPath -Encoding utf8NoBOM +} + + + +<# +.SYNOPSIS + Requires all Authenticode signatures to validate, with the configured publisher and a timestamp. +#> +function Assert-WindowsSignature +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string[]] $Files, + [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ExpectedSubject, + [string] $SignTool = (Get-WindowsSignTool) + ) + + if($Files.Count -eq 0) { throw 'No shipped binaries were supplied for verification.' } + $expected = [Security.Cryptography.X509Certificates.X500DistinguishedName]::new($ExpectedSubject).Name + foreach($file in $Files) + { + $output = (& $SignTool verify /pa /all /v $file 2>&1) -join "`n" + Write-Output $output + if($LASTEXITCODE -ne 0) { throw "SignTool verification failed for '$file'." } + $signature = Get-AuthenticodeSignature -LiteralPath $file + if($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate -or $null -eq $signature.TimeStamperCertificate) + { + throw "Missing valid timestamped Authenticode signature on '$file'." + } + if($signature.SignerCertificate.SubjectName.Name -cne $expected) + { + throw "Unexpected publisher on '$file': $($signature.SignerCertificate.Subject)." + } + $digests = @([regex]::Matches($output, '(?i)Hash of file \(([^)]+)\)') | ForEach-Object { $_.Groups[1].Value }) + if($digests.Count -eq 0 -or @($digests | Where-Object { $_ -ne 'sha256' }).Count -gt 0) + { + throw "SignTool did not report a SHA256 file digest for '$file'. Use the English Windows SDK verification output." + } + } +} + + + +<# +.SYNOPSIS + Verifies signed catalog entries and checks that other binary bytes were preserved. +#> +function Assert-WindowsSigningManifest +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $ManifestPath, + [Parameter(Mandatory)] [string] $ExpectedSubject, + [string] $SignTool = (Get-WindowsSignTool) + ) + + $entries = @(Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json) + $owned = @($entries | Where-Object owned | ForEach-Object path) + Assert-WindowsSignature -Files $owned -ExpectedSubject $ExpectedSubject -SignTool $SignTool + foreach($entry in $entries | Where-Object { -not $_.owned }) + { + if((Get-FileHash -LiteralPath $entry.path -Algorithm SHA256).Hash -ne $entry.sha256) + { + throw "Third-party binary was modified: $($entry.relativePath)." + } + } +} + + + +<# +.SYNOPSIS + Checks signed binaries inside the final ZIP/NuGet assets and their bytes against the signed build. +#> +function Assert-WindowsDistribution +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Directory, + [Parameter(Mandatory)] [string] $ManifestPath, + [Parameter(Mandatory)] [string] $ExpectedSubject, + [string] $SignTool = (Get-WindowsSignTool) + ) + + $entries = @(Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json) + $owned = @($entries | Where-Object owned) + if($owned.Count -eq 0) { throw 'The signing manifest has no owned binaries.' } + $assets = @(Get-ChildItem -LiteralPath $Directory -File) + $archives = @($assets | Where-Object { $_.Extension -in '.zip', '.nupkg' -and $_.Name -notlike '*-delta.nupkg' }) + if($archives.Count -eq 0) { throw 'No final ZIP or full NuGet package was produced.' } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($archive in $archives) + { + $scratch = Join-Path ([IO.Path]::GetTempPath()) ('k22-signing-' + [guid]::NewGuid().ToString('N')) + try + { + [IO.Compression.ZipFile]::ExtractToDirectory($archive.FullName, $scratch) + $files = @(Get-ChildItem -LiteralPath $scratch -File -Recurse) + $archiveSeen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach($entry in $entries) + { + $name = [IO.Path]::GetFileName($entry.path) + $packageRelative = if($entry.relativePath.StartsWith('app/') -or $entry.relativePath.StartsWith('cli/')) { $entry.relativePath.Substring(4) } else { $name } + $copies = @($files | Where-Object { + $relative = [IO.Path]::GetRelativePath($scratch, $_.FullName).Replace('\', '/') + $_.Name -eq $name -and ($relative -eq $packageRelative -or $relative.EndsWith('/' + $packageRelative, [StringComparison]::OrdinalIgnoreCase)) + }) + foreach($copy in $copies) + { + $hash = (Get-FileHash -LiteralPath $copy.FullName -Algorithm SHA256).Hash + $expectedHash = if($entry.owned) { (Get-FileHash -LiteralPath $entry.path -Algorithm SHA256).Hash } else { $entry.sha256 } + if($hash -ne $expectedHash) + { + # The portable archive also carries a generated launcher at its root. + if($entry.owned -and $copy.Extension -eq '.exe' -and $copy.DirectoryName -eq $scratch -and $archive.Name -like '*-Portable.zip') + { + Assert-WindowsSignature -Files @($copy.FullName) -ExpectedSubject $ExpectedSubject -SignTool $SignTool + continue + } + throw "Distributed binary changed in $($archive.Name): $name." + } + if($entry.owned) + { + Assert-WindowsSignature -Files @($copy.FullName) -ExpectedSubject $ExpectedSubject -SignTool $SignTool + $null = $seen.Add($entry.path) + } + $null = $archiveSeen.Add($entry.path) + } + } + $required = @($entries | Where-Object { + if($archive.Name -like '*-cli-*.zip') { return $_.relativePath.StartsWith('cli/') } + if($archive.Name -like '*-Portable.zip' -or $archive.Name -like '*-full.nupkg') + { + # Match Velopack's default exclusions for runtime helpers. + if(-not $_.owned -and $_.relativePath -match '(?:^|/)createdump[^/]*|\.vshost\.') { return $false } + return $_.relativePath.StartsWith('app/') + } + $packageId = [regex]::Escape([IO.Path]::GetFileNameWithoutExtension($_.path)) + return $_.owned -and $archive.Name -match "^$packageId\.[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?\.nupkg$" + }) + foreach($entry in $required) + { + if(-not $archiveSeen.Contains($entry.path)) + { + $kind = if($entry.owned) { 'Owned' } else { 'Third-party' } + throw "$kind binary missing from $($archive.Name): $($entry.relativePath)." + } + } + # Velopack creates these branded helpers before its signing hook. + $updates = @($files | Where-Object { $_.Name -in 'Update.exe', 'Squirrel.exe' -or $_.Name -like '*_ExecutionStub.exe' } | ForEach-Object FullName) + if($updates.Count -gt 0) { Assert-WindowsSignature -Files $updates -ExpectedSubject $ExpectedSubject -SignTool $SignTool } + } + finally + { + $temporaryRoot = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath([IO.Path]::GetTempPath())) + if([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($scratch)) -ne $temporaryRoot -or [IO.Path]::GetFileName($scratch) -notlike 'k22-signing-*') + { + throw 'Refusing to remove a directory outside the signing scratch location.' + } + if(Test-Path -LiteralPath $scratch) { Remove-Item -LiteralPath $scratch -Recurse -Force } + } + } + foreach($entry in $owned) + { + if(-not $seen.Contains($entry.path)) { throw "Owned binary is missing from distributed archives: $($entry.relativePath)." } + } + $installers = @($assets | Where-Object Extension -In '.exe', '.msi', '.msix' | ForEach-Object FullName) + if($installers.Count -gt 0) { Assert-WindowsSignature -Files $installers -ExpectedSubject $ExpectedSubject -SignTool $SignTool } + foreach($setup in $assets | Where-Object Name -Like '*-Setup.exe') + { + $fullPackages = @($assets | Where-Object Name -Like '*-full.nupkg') + if($fullPackages.Count -ne 1) { throw 'Require one current full package to verify the installer payload.' } + Assert-VelopackInstallerPayload -Installer $setup.FullName -FullPackage $fullPackages[0].FullName + } +} + + + +<# +.SYNOPSIS + Requires the signed Velopack installer to embed the exact verified full update package. +#> +function Assert-VelopackInstallerPayload +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Installer, + [Parameter(Mandatory)] [string] $FullPackage + ) + + # Velopack 1.2.161 SetupBundle: two little-endian Int64s followed by this marker. + $marker = '94F0B17B6893E02937EB34EF53AAE7D42B54F5707EF5D6F57854983E5E94ED7D' + $stream = [IO.File]::OpenRead($Installer) + $reader = [Reflection.PortableExecutable.PEReader]::new($stream, [Reflection.PortableExecutable.PEStreamOptions]::LeaveOpen) + $hasher = [Security.Cryptography.IncrementalHash]::CreateHash([Security.Cryptography.HashAlgorithmName]::SHA256) + try + { + $offset = 0L + $length = 0L + $headersFound = 0 + foreach($section in $reader.PEHeaders.SectionHeaders) + { + [byte[]] $bytes = $reader.GetSectionData($section.VirtualAddress).GetContent() + $position = [Convert]::ToHexString($bytes).IndexOf($marker, [StringComparison]::Ordinal) + if($position -lt 32 -or $position % 2 -ne 0) { continue } + $headersFound++ + $offset = [BitConverter]::ToInt64($bytes, $position / 2 - 16) + $length = [BitConverter]::ToInt64($bytes, $position / 2 - 8) + } + if($headersFound -ne 1 -or $offset -le 0 -or $length -le 0 -or $offset -gt $stream.Length -or $length -gt $stream.Length - $offset) + { + throw 'The Velopack installer bundle header is missing, ambiguous or outside the file.' + } + if($length -ne (Get-Item -LiteralPath $FullPackage).Length) { throw 'Installer payload length differs from the full package.' } + $stream.Position = $offset + $buffer = [byte[]]::new(1048576) + $remaining = $length + while($remaining -gt 0) + { + $count = $stream.Read($buffer, 0, [int] [math]::Min($buffer.Length, $remaining)) + if($count -eq 0) { throw 'Unexpected end of the installer payload.' } + $hasher.AppendData($buffer, 0, $count) + $remaining -= $count + } + $hash = [Convert]::ToHexString($hasher.GetHashAndReset()) + if($hash -ne (Get-FileHash -LiteralPath $FullPackage -Algorithm SHA256).Hash) { throw 'Installer embeds a different full package.' } + } + finally + { + $hasher.Dispose() + $reader.Dispose() + $stream.Dispose() + } +} + +Export-ModuleMember -Function Get-WindowsSignTool, New-WindowsSigningCatalog, Assert-WindowsSignature, Assert-WindowsSigningManifest, Assert-WindowsDistribution, Assert-VelopackInstallerPayload