feat(releases): verify Windows Artifact Signing before publication #31

Merged
KofTwentyTwo merged 3 commits from feat/artifact-signing-catalogs into main 2026-10-12 14:53:15 +00:00
11 changed files with 1206 additions and 81 deletions

No files matched your search

@@ -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
+30 -1
View File
@@ -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
+84
View File
@@ -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
+117 -74
View File
@@ -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.<channel>.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<<EOF`n$(($unsigned.FullName) -join "`n")`nEOF" | Add-Content -LiteralPath $env:GITHUB_OUTPUT
- name: Sign published binaries (Azure Trusted Signing)
# Signs the app's own executables and DLLs (and the CLI) before packing, so the
# binaries inside the installer, the portable zip, and the update packages are
# all signed (K22-REL-05).
- name: Sign only owned published binaries (Azure Artifact Signing)
if: inputs.sign
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0
with:
endpoint: ${{ vars.TRUSTED_SIGNING_ENDPOINT }}
signing-account-name: ${{ vars.TRUSTED_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ vars.TRUSTED_SIGNING_PROFILE }}
files: ${{ steps.sign-list.outputs.files }}
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
@@ -378,41 +388,60 @@ jobs:
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
# vpk signs what it adds (Setup.exe, Update.exe, the execution stub) with the
# same identity. It calls this wrapper once per file (--signTemplate) instead
# of its built-in Azure path (--azureTrustedSignFile), which hung for over ten
# minutes on nine files in testing; the wrapper uses the SignTool and the
# Artifact Signing client the signing step above already installed, with the
# Azure CLI login, and skips files that already carry a valid signature.
- name: Write the signing wrapper for vpk
- name: Verify signed binaries and preserved dependencies
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 }}
# vpk signs what it adds (Setup.exe and the update stub) with the same identity,
# reading this metadata file and the Azure CLI login above.
- name: Write signing metadata for vpk
if: inputs.sign
shell: pwsh
env:
TRUSTED_SIGNING_ENDPOINT: ${{ vars.TRUSTED_SIGNING_ENDPOINT }}
TRUSTED_SIGNING_ACCOUNT: ${{ vars.TRUSTED_SIGNING_ACCOUNT }}
TRUSTED_SIGNING_PROFILE: ${{ vars.TRUSTED_SIGNING_PROFILE }}
TRUSTED_SIGNING_ENDPOINT: ${{ vars.ARTIFACT_SIGNING_ENDPOINT || vars.TRUSTED_SIGNING_ENDPOINT }}
TRUSTED_SIGNING_ACCOUNT: ${{ vars.ARTIFACT_SIGNING_ACCOUNT || vars.TRUSTED_SIGNING_ACCOUNT }}
TRUSTED_SIGNING_PROFILE: ${{ vars.ARTIFACT_SIGNING_PROFILE || vars.TRUSTED_SIGNING_PROFILE }}
run: |
@{
Endpoint = $env:TRUSTED_SIGNING_ENDPOINT
CodeSigningAccountName = $env:TRUSTED_SIGNING_ACCOUNT
CertificateProfileName = $env:TRUSTED_SIGNING_PROFILE
ExcludeCredentials = @('EnvironmentCredential', 'WorkloadIdentityCredential', 'ManagedIdentityCredential', 'SharedTokenCacheCredential', 'VisualStudioCredential', 'VisualStudioCodeCredential', 'AzurePowerShellCredential', 'AzureDeveloperCliCredential', 'InteractiveBrowserCredential')
} | ConvertTo-Json | Set-Content -LiteralPath "$env:RUNNER_TEMP\trusted-signing.json" -Encoding utf8
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
Where-Object { $_.FullName -match '\\x64\\' } | Sort-Object FullName -Descending | Select-Object -First 1
$dlib = Get-ChildItem "$env:LOCALAPPDATA\ArtifactSigning" -Recurse -Filter Azure.CodeSigning.Dlib.dll |
Where-Object { $_.FullName -match '\\x64\\' } | Sort-Object FullName -Descending | Select-Object -First 1
if (-not $signtool -or -not $dlib) { throw "signtool.exe ($signtool) or Azure.CodeSigning.Dlib.dll ($dlib) not found." }
@"
param([Parameter(Mandatory)][string] `$File)
`$ErrorActionPreference = 'Stop'
if ((Get-AuthenticodeSignature -LiteralPath `$File).Status -eq 'Valid') { exit 0 }
& '$($signtool.FullName)' sign /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 /dlib '$($dlib.FullName)' /dmdf '$env:RUNNER_TEMP\trusted-signing.json' `$File | Out-Null
if (`$LASTEXITCODE -ne 0) { throw "signtool failed (`$LASTEXITCODE) on `$File" }
"@ | Set-Content -LiteralPath "$env:RUNNER_TEMP\sign-one.ps1" -Encoding utf8
if (-not $signtool -or -not $dlib) { throw 'Installed x64 SignTool or Artifact Signing dlib was not found.' }
foreach ($setting in @{
K22_VPK_SIGN_TOOL = $signtool.FullName
K22_VPK_SIGN_DLIB = $dlib.FullName
K22_VPK_SIGN_METADATA = "$env:RUNNER_TEMP\trusted-signing.json"
}.GetEnumerator())
{
"$($setting.Key)=$($setting.Value)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
}
@'
param([Parameter(Mandatory)][string] $File)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$signature = Get-AuthenticodeSignature -LiteralPath $File
if ($signature.Status -eq 'Valid') { exit 0 }
if ($signature.Status -ne 'NotSigned') { throw "Refusing to replace an invalid signature on $File." }
& $env:K22_VPK_SIGN_TOOL sign /fd SHA256 /tr http://timestamp.acs.microsoft.com /td SHA256 /dlib $env:K22_VPK_SIGN_DLIB /dmdf $env:K22_VPK_SIGN_METADATA $File
if ($LASTEXITCODE -ne 0) { throw "Artifact Signing failed on $File." }
& $env:K22_VPK_SIGN_TOOL verify /pa /all /v $File
if ($LASTEXITCODE -ne 0) { throw "Signature verification failed on $File." }
'@ | Set-Content -LiteralPath "$env:RUNNER_TEMP\sign-one.ps1" -Encoding utf8NoBOM
# The vpk version matches the Velopack NuGet package the apps reference (AppKit).
- name: Install vpk
@@ -436,29 +465,17 @@ jobs:
shell: pwsh
run: |
$sign = @()
if ($env:K22_SIGN -eq 'true') { $sign = @('--signTemplate', "pwsh -NoProfile -NonInteractive -File `"$env:RUNNER_TEMP\sign-one.ps1`" `"{{file}}`"") }
vpk pack --packId $env:K22_APP_ID --packVersion $env:K22_VERSION --packDir publish/app --mainExe $env:K22_MAIN_EXE --channel $env:K22_CHANNEL --icon $env:K22_ICON --outputDir Releases @sign
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# Nothing unsigned leaves this job: Setup.exe and every executable inside the
# portable zip (the app, Update.exe, the execution stub) must verify before the
# release is even drafted (K22-REL-05).
- name: Verify signatures before packaging further
if: inputs.sign
shell: pwsh
run: |
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Recurse -Filter signtool.exe |
Where-Object { $_.FullName -match '\\x64\\' } | Sort-Object FullName -Descending | Select-Object -First 1
Expand-Archive "Releases/$env:K22_APP_ID-$env:K22_CHANNEL-Portable.zip" -DestinationPath "$env:RUNNER_TEMP\portable" -Force
$files = @(Get-Item "Releases/$env:K22_APP_ID-$env:K22_CHANNEL-Setup.exe") + @(Get-ChildItem "$env:RUNNER_TEMP\portable" -Recurse -Include *.exe, *.dll)
$failed = @()
foreach ($f in $files)
if ($env:K22_SIGN -eq 'true')
{
& $signtool.FullName verify /pa /all $f.FullName | Out-Null
if ($LASTEXITCODE -ne 0) { $failed += $f.FullName }
# App/CLI binaries are already signed. Only Velopack's branded helpers
# may be signed inside pack; preserve every other dependency signature.
$exclude = '(?i)[\\/](?!(?:Squirrel\.exe|.*_ExecutionStub\.exe)$)[^\\/]+$'
$command = "pwsh -NoProfile -NonInteractive -File `"$env:RUNNER_TEMP\sign-one.ps1`" `"{{file}}`""
$sign = @('--signTemplate', $command, '--signExclude', $exclude)
}
if ($failed) { Write-Output "::error::Unsigned or invalid after packing:`n$($failed -join "`n")"; exit 1 }
Write-Output "All $($files.Count) packaged executables and libraries verify with signtool."
$env:VELOPACK_TEMP = Join-Path $env:RUNNER_TEMP 'k22-velopack'
vpk pack --runtime $env:K22_RUNTIME --packId $env:K22_APP_ID --packVersion $env:K22_VERSION --packDir publish/app --mainExe $env:K22_MAIN_EXE --channel $env:K22_CHANNEL --icon $env:K22_ICON --outputDir Releases @sign
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# Keeps only what this build produced, as `vpk upload` does: the files listed in
# assets.<channel>.json, plus a releases.<channel>.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
+94
View File
@@ -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.
+12
View File
@@ -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
+4
View File
@@ -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)
+13 -6
View File
@@ -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 <Setup.exe URL of the first stable release>
@@ -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
+84
View File
@@ -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'
}
}
+361
View File
@@ -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 <Archive>' -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*'
}
}
+337
View File
@@ -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