feat(releases): verify Windows Artifact Signing before publication #31
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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*'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user