From a33ecb6464b7c8c8ea0a33d353890dc91acc6ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 25 May 2026 10:20:23 -0400 Subject: [PATCH 1/6] Sign PowerShell module release payload Use the built Devolutions.Psign module during release packaging to sign the staged module manifest, script module, format file, and managed assemblies before PowerShell Gallery packaging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 73 +++++++++++++-- PowerShell/package.ps1 | 59 ++++++++++++- PowerShell/sign-module.ps1 | 137 +++++++++++++++++++++++++++++ docs/portable-powershell-module.md | 2 + 4 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 PowerShell/sign-module.ps1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 919bfec..6e5a843 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -659,6 +659,7 @@ jobs: needs: - preflight - sign_windows + environment: ${{ needs.preflight.outputs.publish_env }} steps: - name: Checkout @@ -677,10 +678,57 @@ jobs: with: dotnet-version: 8.0.x + - name: Resolve PowerShell module signing mode + id: module_signing_mode + shell: pwsh + env: + CODE_SIGNING_KEYVAULT_URL: ${{ secrets.CODE_SIGNING_KEYVAULT_URL }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + CODE_SIGNING_CLIENT_ID: ${{ secrets.CODE_SIGNING_CLIENT_ID }} + CODE_SIGNING_CLIENT_SECRET: ${{ secrets.CODE_SIGNING_CLIENT_SECRET }} + CODE_SIGNING_CERTIFICATE_NAME: ${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }} + CODE_SIGNING_TIMESTAMP_SERVER: ${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }} + run: | + $dryRun = [System.Boolean]::Parse('${{ needs.preflight.outputs.dry_run }}') + $signDryRun = [System.Boolean]::Parse('${{ github.event.inputs.sign_dry_run }}') + + $requiredValues = @( + $env:CODE_SIGNING_KEYVAULT_URL, + $env:AZURE_TENANT_ID, + $env:CODE_SIGNING_CLIENT_ID, + $env:CODE_SIGNING_CLIENT_SECRET, + $env:CODE_SIGNING_CERTIFICATE_NAME, + $env:CODE_SIGNING_TIMESTAMP_SERVER + ) + + $hasSigningSecrets = $true + foreach ($value in $requiredValues) { + if ([string]::IsNullOrWhiteSpace($value)) { + $hasSigningSecrets = $false + break + } + } + + $shouldSign = $hasSigningSecrets -and ((-not $dryRun) -or $signDryRun) + + if (-not $dryRun -and -not $hasSigningSecrets) { + throw 'Code signing secrets are required for non-dry-run release jobs.' + } + + "should_sign=$($shouldSign.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Host "PowerShell module payload will be signed: $shouldSign" + - name: Package PowerShell module shell: pwsh env: RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }} + SIGN_POWERSHELL_MODULE: ${{ steps.module_signing_mode.outputs.should_sign }} + CODE_SIGNING_KEYVAULT_URL: ${{ secrets.CODE_SIGNING_KEYVAULT_URL }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + CODE_SIGNING_CLIENT_ID: ${{ secrets.CODE_SIGNING_CLIENT_ID }} + CODE_SIGNING_CLIENT_SECRET: ${{ secrets.CODE_SIGNING_CLIENT_SECRET }} + CODE_SIGNING_CERTIFICATE_NAME: ${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }} + CODE_SIGNING_TIMESTAMP_SERVER: ${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }} run: | $tag = $env:RELEASE_TAG if (-not $tag.StartsWith('v')) { @@ -691,12 +739,25 @@ jobs: New-Item -ItemType Directory -Force -Path dist | Out-Null $archivePath = Join-Path 'dist' "Devolutions.Psign.$version.zip" - ./PowerShell/package.ps1 ` - -Configuration Release ` - -NativeArtifactsRoot 'dist/native' ` - -SkipNativeBuild ` - -OutputDirectory 'dist/powershell' ` - -ModuleArchivePath $archivePath + $packageArgs = @{ + Configuration = 'Release' + NativeArtifactsRoot = 'dist/native' + SkipNativeBuild = $true + OutputDirectory = 'dist/powershell' + ModuleArchivePath = $archivePath + } + + if ([System.Boolean]::Parse($env:SIGN_POWERSHELL_MODULE)) { + $packageArgs.SignModule = $true + $packageArgs.AzureKeyVaultUrl = $env:CODE_SIGNING_KEYVAULT_URL + $packageArgs.AzureKeyVaultCertificate = $env:CODE_SIGNING_CERTIFICATE_NAME + $packageArgs.AzureKeyVaultClientId = $env:CODE_SIGNING_CLIENT_ID + $packageArgs.AzureKeyVaultClientSecret = $env:CODE_SIGNING_CLIENT_SECRET + $packageArgs.AzureKeyVaultTenantId = $env:AZURE_TENANT_ID + $packageArgs.TimestampServer = $env:CODE_SIGNING_TIMESTAMP_SERVER + } + + ./PowerShell/package.ps1 @packageArgs - name: Upload PowerShell module package artifact uses: actions/upload-artifact@v7 diff --git a/PowerShell/package.ps1 b/PowerShell/package.ps1 index b214f0e..77f0239 100644 --- a/PowerShell/package.ps1 +++ b/PowerShell/package.ps1 @@ -8,7 +8,27 @@ param( [switch] $SkipNativeBuild, - [string] $ModuleArchivePath + [string] $ModuleArchivePath, + + [switch] $SignModule, + + [string] $AzureKeyVaultUrl, + + [string] $AzureKeyVaultCertificate, + + [string] $AzureKeyVaultClientId, + + [string] $AzureKeyVaultClientSecret, + + [string] $AzureKeyVaultTenantId, + + [string] $TimestampServer, + + [ValidateSet('Sha256', 'Sha384', 'Sha512')] + [string] $HashAlgorithm = 'Sha256', + + [ValidateSet('Sha1', 'Sha256', 'Sha384', 'Sha512')] + [string] $TimestampHashAlgorithm = 'Sha256' ) $ErrorActionPreference = 'Stop' @@ -17,6 +37,7 @@ $repo = Split-Path -Parent $PSScriptRoot $moduleRoot = Join-Path $PSScriptRoot 'Devolutions.Psign' $localRepo = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) $installRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) +$stagingRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N')) $repoName = "DevolutionsPsignLocal$([System.Guid]::NewGuid().ToString('N'))" $buildArgs = @{ @@ -49,9 +70,36 @@ foreach ($cmdlet in $expectedCmdlets) { } } +$packageModuleRoot = $moduleRoot +if ($SignModule) { + New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null + Copy-Item -LiteralPath $moduleRoot -Destination $stagingRoot -Recurse -Force + $packageModuleRoot = Join-Path $stagingRoot (Split-Path -Leaf $moduleRoot) + + & (Join-Path $PSScriptRoot 'sign-module.ps1') ` + -ModuleRoot $packageModuleRoot ` + -SignerModuleRoot $moduleRoot ` + -AzureKeyVaultUrl $AzureKeyVaultUrl ` + -AzureKeyVaultCertificate $AzureKeyVaultCertificate ` + -AzureKeyVaultClientId $AzureKeyVaultClientId ` + -AzureKeyVaultClientSecret $AzureKeyVaultClientSecret ` + -AzureKeyVaultTenantId $AzureKeyVaultTenantId ` + -TimestampServer $TimestampServer ` + -HashAlgorithm $HashAlgorithm ` + -TimestampHashAlgorithm $TimestampHashAlgorithm | Out-Host + + $manifestPath = Join-Path $packageModuleRoot 'Devolutions.Psign.psd1' + $manifest = Test-ModuleManifest -Path $manifestPath + foreach ($cmdlet in $expectedCmdlets) { + if ($manifest.ExportedCmdlets.Keys -notcontains $cmdlet) { + throw "Signed module manifest does not export expected cmdlet '$cmdlet'." + } + } +} + try { Register-PSRepository -Name $repoName -SourceLocation $localRepo -PublishLocation $localRepo -InstallationPolicy Trusted - Publish-Module -Path $moduleRoot -Repository $repoName -NuGetApiKey 'local-package' + Publish-Module -Path $packageModuleRoot -Repository $repoName -NuGetApiKey 'local-package' $package = Get-ChildItem -Path $localRepo -Filter 'Devolutions.Psign.*.nupkg' | Sort-Object LastWriteTimeUtc -Descending | @@ -69,6 +117,10 @@ try { throw "Installed package smoke test did not find cmdlet '$cmdlet'." } } + if ($SignModule) { + $savedModuleRoot = Split-Path -Parent $savedManifest + & (Join-Path $PSScriptRoot 'sign-module.ps1') -ModuleRoot $savedModuleRoot -VerifyOnly | Out-Host + } $nativeProbe = New-TemporaryFile try { $null = Get-PsignSignature -LiteralPath $nativeProbe.FullName -ErrorAction Stop @@ -80,7 +132,7 @@ try { if (Test-Path -LiteralPath $ModuleArchivePath) { Remove-Item -LiteralPath $ModuleArchivePath -Force } - Compress-Archive -Path $moduleRoot -DestinationPath $ModuleArchivePath -Force + Compress-Archive -Path $packageModuleRoot -DestinationPath $ModuleArchivePath -Force } Get-Item -LiteralPath (Join-Path $OutputDirectory $package.Name) } @@ -89,4 +141,5 @@ finally { Unregister-PSRepository -Name $repoName -ErrorAction SilentlyContinue Remove-Item -LiteralPath $localRepo -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/PowerShell/sign-module.ps1 b/PowerShell/sign-module.ps1 new file mode 100644 index 0000000..d45071c --- /dev/null +++ b/PowerShell/sign-module.ps1 @@ -0,0 +1,137 @@ +param( + [Parameter(Mandatory = $true)] + [string] $ModuleRoot, + + [string] $SignerModuleRoot, + + [switch] $VerifyOnly, + + [string] $AzureKeyVaultUrl, + + [string] $AzureKeyVaultCertificate, + + [string] $AzureKeyVaultClientId, + + [string] $AzureKeyVaultClientSecret, + + [string] $AzureKeyVaultTenantId, + + [string] $TimestampServer, + + [ValidateSet('Sha256', 'Sha384', 'Sha512')] + [string] $HashAlgorithm = 'Sha256', + + [ValidateSet('Sha1', 'Sha256', 'Sha384', 'Sha512')] + [string] $TimestampHashAlgorithm = 'Sha256' +) + +$ErrorActionPreference = 'Stop' + +function Get-PsignModuleSigningTargets { + param( + [Parameter(Mandatory = $true)] + [string] $ResolvedModuleRoot + ) + + $topLevelExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($extension in @('.psd1', '.psm1', '.ps1xml')) { + [void] $topLevelExtensions.Add($extension) + } + + $targets = [System.Collections.Generic.List[string]]::new() + Get-ChildItem -LiteralPath $ResolvedModuleRoot -File | + Where-Object { $topLevelExtensions.Contains($_.Extension) } | + Sort-Object FullName | + ForEach-Object { [void] $targets.Add($_.FullName) } + + $managedAssemblyRoot = Join-Path (Join-Path $ResolvedModuleRoot 'lib') 'net8.0' + if (Test-Path -LiteralPath $managedAssemblyRoot) { + Get-ChildItem -LiteralPath $managedAssemblyRoot -Filter '*.dll' -File | + Sort-Object FullName | + ForEach-Object { [void] $targets.Add($_.FullName) } + } + + return $targets.ToArray() +} + +function Assert-PsignModuleSigningParameters { + foreach ($name in @( + 'AzureKeyVaultUrl', + 'AzureKeyVaultCertificate', + 'AzureKeyVaultClientId', + 'AzureKeyVaultClientSecret', + 'AzureKeyVaultTenantId', + 'TimestampServer' + )) { + if ([string]::IsNullOrWhiteSpace((Get-Variable -Name $name -ValueOnly))) { + throw "$name is required when signing a PowerShell module release payload." + } + } +} + +function Assert-PsignModuleSignatures { + param( + [Parameter(Mandatory = $true)] + [string[]] $Targets + ) + + foreach ($target in $Targets) { + $signature = Get-PsignSignature -LiteralPath $target -ErrorAction Stop + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + $relativePath = Resolve-Path -LiteralPath $target -Relative + throw "Expected valid Authenticode signature for '$relativePath', got '$($signature.Status)': $($signature.StatusMessage)" + } + } +} + +$resolvedModuleRoot = (Resolve-Path -LiteralPath $ModuleRoot).Path +$manifestPath = Join-Path $resolvedModuleRoot 'Devolutions.Psign.psd1' +if (-not (Test-Path -LiteralPath $manifestPath)) { + throw "Devolutions.Psign manifest not found at '$manifestPath'." +} + +$resolvedSignerModuleRoot = if ([string]::IsNullOrWhiteSpace($SignerModuleRoot)) { + $resolvedModuleRoot +} else { + (Resolve-Path -LiteralPath $SignerModuleRoot).Path +} +$signerManifestPath = Join-Path $resolvedSignerModuleRoot 'Devolutions.Psign.psd1' +if (-not (Test-Path -LiteralPath $signerManifestPath)) { + throw "Devolutions.Psign signer manifest not found at '$signerManifestPath'." +} + +$targets = @(Get-PsignModuleSigningTargets -ResolvedModuleRoot $resolvedModuleRoot) +if ($targets.Count -eq 0) { + throw "No PowerShell module signing targets were found under '$resolvedModuleRoot'." +} + +Import-Module $signerManifestPath -Force -ErrorAction Stop + +if (-not $VerifyOnly.IsPresent) { + Assert-PsignModuleSigningParameters + + $signingArguments = @{ + AzureKeyVaultUrl = $AzureKeyVaultUrl + AzureKeyVaultCertificate = $AzureKeyVaultCertificate + AzureKeyVaultClientId = $AzureKeyVaultClientId + AzureKeyVaultClientSecret = $AzureKeyVaultClientSecret + AzureKeyVaultTenantId = $AzureKeyVaultTenantId + TimestampServer = $TimestampServer + HashAlgorithm = $HashAlgorithm + TimestampHashAlgorithm = $TimestampHashAlgorithm + Force = $true + } + + foreach ($target in $targets) { + Set-PsignSignature -LiteralPath $target @signingArguments -ErrorAction Stop | Out-Null + } +} + +Assert-PsignModuleSignatures -Targets $targets + +[pscustomobject]@{ + ModuleRoot = $resolvedModuleRoot + Signed = -not $VerifyOnly.IsPresent + TargetCount = $targets.Count + Targets = $targets +} diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 27ab0b6..2d54048 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -86,6 +86,8 @@ pwsh -File .\PowerShell\package.ps1 -Configuration Release -NativeArtifactsRoot The native artifact root should contain directories such as `psign-core-win-x64`, `psign-core-linux-x64`, and `psign-core-osx-arm64`, each containing the packaged native library name for that RID. +Release packaging can also sign the staged module payload before creating the `.nupkg`. The release workflow uses the built `Devolutions.Psign` module to Authenticode-sign the module manifest, root script module, format file, and managed assemblies, while preserving the separately signed Windows native `psign-core.dll` artifacts imported from the native signing job. The release ZIP remains only a transport archive for those signed files; it is not signed as a custom ZIP Authenticode package. + ## Migrating from built-in Authenticode cmdlets The portable module cmdlets (`Get-PsignSignature`, `Set-PsignSignature`) are designed as near drop-in replacements for `Get-AuthenticodeSignature` and `Set-AuthenticodeSignature`. The following table shows common migration patterns: From 267692d02e63349ad624d4eb3e52f952ee97b01a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 25 May 2026 10:32:40 -0400 Subject: [PATCH 2/6] Enable Key Vault signing in module native artifacts Build psign-portable-ffi with the azure-kv-sign feature in release artifacts so the packaged PowerShell module can sign its own payload during dry-run and release packaging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e5a843..f8aa4aa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,7 +253,7 @@ jobs: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | $target = '${{ matrix.target }}' - cargo build --locked --release -p psign-portable-ffi --target $target + cargo build --locked --release -p psign-portable-ffi --features azure-kv-sign --target $target - name: Stage psign-core native library shell: pwsh From ce52ac4dbca7ad504242acae16cee4b636674749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 25 May 2026 10:46:13 -0400 Subject: [PATCH 3/6] Use workflow psign-tool for module Key Vault signing Use the release-built psign-tool for Azure Key Vault signing of the staged PowerShell module payload, then verify the signed files through the built module. This avoids the module API's current lack of direct Key Vault signing support and keeps the release ZIP as an unsigned transport archive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 21 +++++++++++- PowerShell/package.ps1 | 3 ++ PowerShell/sign-module.ps1 | 53 +++++++++++++++++++++++------- docs/portable-powershell-module.md | 2 +- 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8aa4aa..ca2e20c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,7 +253,7 @@ jobs: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | $target = '${{ matrix.target }}' - cargo build --locked --release -p psign-portable-ffi --features azure-kv-sign --target $target + cargo build --locked --release -p psign-portable-ffi --target $target - name: Stage psign-core native library shell: pwsh @@ -673,6 +673,12 @@ jobs: pattern: psign-core-* path: dist/native + - name: Download Linux x64 psign-tool + uses: actions/download-artifact@v8 + with: + name: psign-tool-linux-x64.zip + path: work/linux-x64 + - name: Setup .NET 8 uses: actions/setup-dotnet@v5 with: @@ -748,6 +754,18 @@ jobs: } if ([System.Boolean]::Parse($env:SIGN_POWERSHELL_MODULE)) { + $toolDir = Join-Path $env:RUNNER_TEMP 'psign-tool-module-signing' + if (Test-Path -LiteralPath $toolDir) { + Remove-Item -LiteralPath $toolDir -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $toolDir | Out-Null + Expand-Archive -Path 'work/linux-x64/psign-tool-linux-x64.zip' -DestinationPath $toolDir -Force + $psignToolPath = Join-Path $toolDir 'psign-tool' + if (-not (Test-Path -LiteralPath $psignToolPath)) { + throw "Built Linux psign-tool executable was not found at $psignToolPath" + } + chmod +x $psignToolPath + $packageArgs.SignModule = $true $packageArgs.AzureKeyVaultUrl = $env:CODE_SIGNING_KEYVAULT_URL $packageArgs.AzureKeyVaultCertificate = $env:CODE_SIGNING_CERTIFICATE_NAME @@ -755,6 +773,7 @@ jobs: $packageArgs.AzureKeyVaultClientSecret = $env:CODE_SIGNING_CLIENT_SECRET $packageArgs.AzureKeyVaultTenantId = $env:AZURE_TENANT_ID $packageArgs.TimestampServer = $env:CODE_SIGNING_TIMESTAMP_SERVER + $packageArgs.PsignToolPath = $psignToolPath } ./PowerShell/package.ps1 @packageArgs diff --git a/PowerShell/package.ps1 b/PowerShell/package.ps1 index 77f0239..57a6c14 100644 --- a/PowerShell/package.ps1 +++ b/PowerShell/package.ps1 @@ -24,6 +24,8 @@ param( [string] $TimestampServer, + [string] $PsignToolPath, + [ValidateSet('Sha256', 'Sha384', 'Sha512')] [string] $HashAlgorithm = 'Sha256', @@ -85,6 +87,7 @@ if ($SignModule) { -AzureKeyVaultClientSecret $AzureKeyVaultClientSecret ` -AzureKeyVaultTenantId $AzureKeyVaultTenantId ` -TimestampServer $TimestampServer ` + -PsignToolPath $PsignToolPath ` -HashAlgorithm $HashAlgorithm ` -TimestampHashAlgorithm $TimestampHashAlgorithm | Out-Host diff --git a/PowerShell/sign-module.ps1 b/PowerShell/sign-module.ps1 index d45071c..ebb4ff6 100644 --- a/PowerShell/sign-module.ps1 +++ b/PowerShell/sign-module.ps1 @@ -4,6 +4,8 @@ param( [string] $SignerModuleRoot, + [string] $PsignToolPath, + [switch] $VerifyOnly, [string] $AzureKeyVaultUrl, @@ -69,6 +71,37 @@ function Assert-PsignModuleSigningParameters { } } +function Invoke-PsignToolModuleSigning { + param( + [Parameter(Mandatory = $true)] + [string] $Target, + + [Parameter(Mandatory = $true)] + [string] $ToolPath + ) + + $toolArguments = @( + '--mode', 'portable', + '--verbose', + 'sign', + '--azure-key-vault-tenant-id', $AzureKeyVaultTenantId, + '--azure-key-vault-url', $AzureKeyVaultUrl, + '--azure-key-vault-client-id', $AzureKeyVaultClientId, + '--azure-key-vault-client-secret', $AzureKeyVaultClientSecret, + '--azure-key-vault-certificate', $AzureKeyVaultCertificate, + '--timestamp-url', $TimestampServer, + '--timestamp-digest', $TimestampHashAlgorithm.ToLowerInvariant(), + '--digest', $HashAlgorithm.ToLowerInvariant(), + '--exit-codes', 'azure', + $Target + ) + + & $ToolPath @toolArguments + if ($LASTEXITCODE -ne 0) { + throw "psign-tool signing failed for '$Target' with exit code $LASTEXITCODE." + } +} + function Assert-PsignModuleSignatures { param( [Parameter(Mandatory = $true)] @@ -110,20 +143,18 @@ Import-Module $signerManifestPath -Force -ErrorAction Stop if (-not $VerifyOnly.IsPresent) { Assert-PsignModuleSigningParameters - $signingArguments = @{ - AzureKeyVaultUrl = $AzureKeyVaultUrl - AzureKeyVaultCertificate = $AzureKeyVaultCertificate - AzureKeyVaultClientId = $AzureKeyVaultClientId - AzureKeyVaultClientSecret = $AzureKeyVaultClientSecret - AzureKeyVaultTenantId = $AzureKeyVaultTenantId - TimestampServer = $TimestampServer - HashAlgorithm = $HashAlgorithm - TimestampHashAlgorithm = $TimestampHashAlgorithm - Force = $true + $resolvedPsignToolPath = if ([string]::IsNullOrWhiteSpace($PsignToolPath)) { + $command = Get-Command psign-tool -ErrorAction SilentlyContinue + if ($null -eq $command) { + throw "PsignToolPath is required because Azure Key Vault signing is not available through Set-PsignSignature." + } + $command.Source + } else { + (Resolve-Path -LiteralPath $PsignToolPath).Path } foreach ($target in $targets) { - Set-PsignSignature -LiteralPath $target @signingArguments -ErrorAction Stop | Out-Null + Invoke-PsignToolModuleSigning -Target $target -ToolPath $resolvedPsignToolPath } } diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 2d54048..54e619b 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -86,7 +86,7 @@ pwsh -File .\PowerShell\package.ps1 -Configuration Release -NativeArtifactsRoot The native artifact root should contain directories such as `psign-core-win-x64`, `psign-core-linux-x64`, and `psign-core-osx-arm64`, each containing the packaged native library name for that RID. -Release packaging can also sign the staged module payload before creating the `.nupkg`. The release workflow uses the built `Devolutions.Psign` module to Authenticode-sign the module manifest, root script module, format file, and managed assemblies, while preserving the separately signed Windows native `psign-core.dll` artifacts imported from the native signing job. The release ZIP remains only a transport archive for those signed files; it is not signed as a custom ZIP Authenticode package. +Release packaging can also sign the staged module payload before creating the `.nupkg`. The release workflow Authenticode-signs the module manifest, root script module, format file, and managed assemblies, then verifies them through the built `Devolutions.Psign` module, while preserving the separately signed Windows native `psign-core.dll` artifacts imported from the native signing job. Azure Key Vault signing uses the workflow-built `psign-tool` because the module API does not yet expose the Key Vault signer directly. The release ZIP remains only a transport archive for those signed files; it is not signed as a custom ZIP Authenticode package. ## Migrating from built-in Authenticode cmdlets From 2e4ad93abe083bc5bc9226ef270101d973a40090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 25 May 2026 11:41:23 -0400 Subject: [PATCH 4/6] Enable module cloud signing Add portable cloud-signing providers to psign-portable-core so Set-PsignSignature can sign PE and PowerShell-class script payloads through Azure Key Vault or Artifact Signing. Build the PowerShell module native libraries with those features and remove the temporary psign-tool module-signing fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 21 +- Cargo.lock | 1 + PowerShell/build.ps1 | 2 +- PowerShell/package.ps1 | 3 - PowerShell/sign-module.ps1 | 53 +- crates/psign-portable-core/Cargo.toml | 2 +- crates/psign-portable-core/src/lib.rs | 728 +++++++++++++++++++++----- crates/psign-sip-digest/src/pkcs7.rs | 20 +- docs/portable-powershell-module.md | 2 +- 9 files changed, 619 insertions(+), 213 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca2e20c..aca4d72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,7 +253,7 @@ jobs: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | $target = '${{ matrix.target }}' - cargo build --locked --release -p psign-portable-ffi --target $target + cargo build --locked --release -p psign-portable-ffi --features azure-kv-sign,artifact-signing-rest --target $target - name: Stage psign-core native library shell: pwsh @@ -673,12 +673,6 @@ jobs: pattern: psign-core-* path: dist/native - - name: Download Linux x64 psign-tool - uses: actions/download-artifact@v8 - with: - name: psign-tool-linux-x64.zip - path: work/linux-x64 - - name: Setup .NET 8 uses: actions/setup-dotnet@v5 with: @@ -754,18 +748,6 @@ jobs: } if ([System.Boolean]::Parse($env:SIGN_POWERSHELL_MODULE)) { - $toolDir = Join-Path $env:RUNNER_TEMP 'psign-tool-module-signing' - if (Test-Path -LiteralPath $toolDir) { - Remove-Item -LiteralPath $toolDir -Recurse -Force - } - New-Item -ItemType Directory -Force -Path $toolDir | Out-Null - Expand-Archive -Path 'work/linux-x64/psign-tool-linux-x64.zip' -DestinationPath $toolDir -Force - $psignToolPath = Join-Path $toolDir 'psign-tool' - if (-not (Test-Path -LiteralPath $psignToolPath)) { - throw "Built Linux psign-tool executable was not found at $psignToolPath" - } - chmod +x $psignToolPath - $packageArgs.SignModule = $true $packageArgs.AzureKeyVaultUrl = $env:CODE_SIGNING_KEYVAULT_URL $packageArgs.AzureKeyVaultCertificate = $env:CODE_SIGNING_CERTIFICATE_NAME @@ -773,7 +755,6 @@ jobs: $packageArgs.AzureKeyVaultClientSecret = $env:CODE_SIGNING_CLIENT_SECRET $packageArgs.AzureKeyVaultTenantId = $env:AZURE_TENANT_ID $packageArgs.TimestampServer = $env:CODE_SIGNING_TIMESTAMP_SERVER - $packageArgs.PsignToolPath = $psignToolPath } ./PowerShell/package.ps1 @packageArgs diff --git a/Cargo.lock b/Cargo.lock index 8dc5862..95d046c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2127,6 +2127,7 @@ name = "psign-portable-core" version = "0.4.0" dependencies = [ "anyhow", + "authenticode", "base64", "der 0.7.10", "picky", diff --git a/PowerShell/build.ps1 b/PowerShell/build.ps1 index f87bfb3..b32af0d 100644 --- a/PowerShell/build.ps1 +++ b/PowerShell/build.ps1 @@ -28,7 +28,7 @@ try { return } - cargo build -p psign-portable-ffi --profile ($Configuration -eq 'Release' ? 'release' : 'dev') + cargo build -p psign-portable-ffi --features azure-kv-sign,artifact-signing-rest --profile ($Configuration -eq 'Release' ? 'release' : 'dev') $rid = if ($IsWindows) { 'win' diff --git a/PowerShell/package.ps1 b/PowerShell/package.ps1 index 57a6c14..77f0239 100644 --- a/PowerShell/package.ps1 +++ b/PowerShell/package.ps1 @@ -24,8 +24,6 @@ param( [string] $TimestampServer, - [string] $PsignToolPath, - [ValidateSet('Sha256', 'Sha384', 'Sha512')] [string] $HashAlgorithm = 'Sha256', @@ -87,7 +85,6 @@ if ($SignModule) { -AzureKeyVaultClientSecret $AzureKeyVaultClientSecret ` -AzureKeyVaultTenantId $AzureKeyVaultTenantId ` -TimestampServer $TimestampServer ` - -PsignToolPath $PsignToolPath ` -HashAlgorithm $HashAlgorithm ` -TimestampHashAlgorithm $TimestampHashAlgorithm | Out-Host diff --git a/PowerShell/sign-module.ps1 b/PowerShell/sign-module.ps1 index ebb4ff6..f2e7259 100644 --- a/PowerShell/sign-module.ps1 +++ b/PowerShell/sign-module.ps1 @@ -4,8 +4,6 @@ param( [string] $SignerModuleRoot, - [string] $PsignToolPath, - [switch] $VerifyOnly, [string] $AzureKeyVaultUrl, @@ -71,35 +69,24 @@ function Assert-PsignModuleSigningParameters { } } -function Invoke-PsignToolModuleSigning { +function Invoke-PsignModuleSigning { param( [Parameter(Mandatory = $true)] - [string] $Target, - - [Parameter(Mandatory = $true)] - [string] $ToolPath - ) - - $toolArguments = @( - '--mode', 'portable', - '--verbose', - 'sign', - '--azure-key-vault-tenant-id', $AzureKeyVaultTenantId, - '--azure-key-vault-url', $AzureKeyVaultUrl, - '--azure-key-vault-client-id', $AzureKeyVaultClientId, - '--azure-key-vault-client-secret', $AzureKeyVaultClientSecret, - '--azure-key-vault-certificate', $AzureKeyVaultCertificate, - '--timestamp-url', $TimestampServer, - '--timestamp-digest', $TimestampHashAlgorithm.ToLowerInvariant(), - '--digest', $HashAlgorithm.ToLowerInvariant(), - '--exit-codes', 'azure', - $Target + [string] $Target ) - & $ToolPath @toolArguments - if ($LASTEXITCODE -ne 0) { - throw "psign-tool signing failed for '$Target' with exit code $LASTEXITCODE." - } + Set-PsignSignature ` + -LiteralPath $Target ` + -AzureKeyVaultUrl $AzureKeyVaultUrl ` + -AzureKeyVaultCertificate $AzureKeyVaultCertificate ` + -AzureKeyVaultClientId $AzureKeyVaultClientId ` + -AzureKeyVaultClientSecret $AzureKeyVaultClientSecret ` + -AzureKeyVaultTenantId $AzureKeyVaultTenantId ` + -TimestampServer $TimestampServer ` + -TimestampHashAlgorithm $TimestampHashAlgorithm ` + -HashAlgorithm $HashAlgorithm ` + -Force ` + -ErrorAction Stop | Out-Null } function Assert-PsignModuleSignatures { @@ -143,18 +130,8 @@ Import-Module $signerManifestPath -Force -ErrorAction Stop if (-not $VerifyOnly.IsPresent) { Assert-PsignModuleSigningParameters - $resolvedPsignToolPath = if ([string]::IsNullOrWhiteSpace($PsignToolPath)) { - $command = Get-Command psign-tool -ErrorAction SilentlyContinue - if ($null -eq $command) { - throw "PsignToolPath is required because Azure Key Vault signing is not available through Set-PsignSignature." - } - $command.Source - } else { - (Resolve-Path -LiteralPath $PsignToolPath).Path - } - foreach ($target in $targets) { - Invoke-PsignToolModuleSigning -Target $target -ToolPath $resolvedPsignToolPath + Invoke-PsignModuleSigning -Target $target } } diff --git a/crates/psign-portable-core/Cargo.toml b/crates/psign-portable-core/Cargo.toml index c56fca9..d855d2f 100644 --- a/crates/psign-portable-core/Cargo.toml +++ b/crates/psign-portable-core/Cargo.toml @@ -13,6 +13,7 @@ artifact-signing-rest = ["dep:psign-codesigning-rest"] [dependencies] anyhow = "1" +authenticode = { version = "0.5.0", features = ["std", "object"] } base64 = "0.22" der = "0.7" psign-authenticode-trust = { path = "../psign-authenticode-trust" } @@ -29,4 +30,3 @@ sha1 = { version = "0.10", default-features = false } sha2 = "0.10" x509-cert = { version = "0.2.5", default-features = false, features = ["pem"] } zip = { version = "0.6.6", default-features = false, features = ["deflate"] } - diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 6d87c0c..9f7df69 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -4,6 +4,7 @@ use std::io::{Cursor, Read, Write}; use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; +use authenticode::SpcIndirectDataContent; use base64::Engine as _; use der::Encode as _; use picky::key::PrivateKey; @@ -21,7 +22,7 @@ use psign_authenticode_trust::{ use psign_sip_digest::pkcs7::AuthenticodeSigningDigest; use psign_sip_digest::verify_pe::verify_pe_authenticode_digest_consistency; use psign_sip_digest::{ - cab_digest, msi_digest, msix_digest, pe_embed, pkcs7, ps_script, rdp, timestamp, + cab_digest, msi_digest, msix_digest, pe_digest, pe_embed, pkcs7, ps_script, rdp, timestamp, verify_script_digest_consistency, zip_authenticode, }; use serde::{Deserialize, Serialize}; @@ -483,23 +484,518 @@ pub fn portable_error_response( } } -fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result<()> { - let pe = - std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; +enum SigningProvider { + Local(Box), + #[cfg(feature = "azure-kv-sign")] + AzureKeyVault(Box), + #[cfg(feature = "artifact-signing-rest")] + ArtifactSigning(Box), +} + +struct LocalSigningProvider { + signer_cert: x509_cert::Certificate, + private_key: rsa::RsaPrivateKey, + chain: Vec, +} + +#[cfg(feature = "azure-kv-sign")] +struct AzureKeyVaultSigningProvider { + http: reqwest::blocking::Client, + token: String, + key_vault_certificate: psign_azure_kv_rest::KeyVaultCertificate, + signer_cert: x509_cert::Certificate, + chain: Vec, +} + +#[cfg(feature = "artifact-signing-rest")] +struct ArtifactSigningProvider { + endpoint: String, + account_name: String, + profile_name: String, + auth: psign_codesigning_rest::CodesigningAuth, + chain: Vec, +} + +#[cfg(any(feature = "azure-kv-sign", feature = "artifact-signing-rest"))] +struct RemoteSignature { + signature: Vec, + signer_cert: x509_cert::Certificate, + chain: Vec, +} + +impl SigningProvider { + fn create_authenticode_pkcs7( + &self, + indirect: SpcIndirectDataContent, + digest_algorithm: AuthenticodeSigningDigest, + ) -> Result> { + match self { + SigningProvider::Local(local) => pkcs7::create_authenticode_pkcs7_der_rsa( + indirect, + digest_algorithm, + local.signer_cert.clone(), + local.chain.clone(), + local.private_key.clone(), + ), + #[cfg(feature = "azure-kv-sign")] + SigningProvider::AzureKeyVault(_) => { + let prehash = pkcs7::authenticode_remote_rsa_signed_attrs_digest( + &indirect, + digest_algorithm, + )?; + let signed = self.sign_remote_digest(digest_algorithm, &prehash)?; + pkcs7::create_authenticode_pkcs7_der_with_rsa_signature( + indirect, + digest_algorithm, + signed.signer_cert, + signed.chain, + &signed.signature, + ) + } + #[cfg(feature = "artifact-signing-rest")] + SigningProvider::ArtifactSigning(_) => { + let prehash = pkcs7::authenticode_remote_rsa_signed_attrs_digest( + &indirect, + digest_algorithm, + )?; + let signed = self.sign_remote_digest(digest_algorithm, &prehash)?; + pkcs7::create_authenticode_pkcs7_der_with_rsa_signature( + indirect, + digest_algorithm, + signed.signer_cert, + signed.chain, + &signed.signature, + ) + } + } + } + + fn create_pkcs7_signed_data( + &self, + econtent_type: der::asn1::ObjectIdentifier, + econtent_der: &[u8], + digest_algorithm: AuthenticodeSigningDigest, + content_mode: pkcs7::Pkcs7ContentMode, + ) -> Result> { + match self { + SigningProvider::Local(local) => { + let pkcs7_bytes = pkcs7::create_pkcs7_signed_data_der_rsa( + econtent_type, + econtent_der, + digest_algorithm, + local.signer_cert.clone(), + local.chain.clone(), + local.private_key.clone(), + )?; + if content_mode == pkcs7::Pkcs7ContentMode::Attached { + return Ok(pkcs7_bytes); + } + let mut sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7_bytes) + .context("parse generated CMS before detaching eContent")?; + sd.encap_content_info.econtent = None; + pkcs7::encode_pkcs7_content_info_signed_data_der(&sd) + } + #[cfg(feature = "azure-kv-sign")] + SigningProvider::AzureKeyVault(_) => { + let prehash = pkcs7::pkcs7_remote_rsa_signed_attrs_digest( + econtent_type, + econtent_der, + digest_algorithm, + )?; + let signed = self.sign_remote_digest(digest_algorithm, &prehash)?; + pkcs7::create_pkcs7_signed_data_der_with_rsa_signature( + econtent_type, + econtent_der, + digest_algorithm, + signed.signer_cert, + signed.chain, + &signed.signature, + content_mode, + ) + } + #[cfg(feature = "artifact-signing-rest")] + SigningProvider::ArtifactSigning(_) => { + let prehash = pkcs7::pkcs7_remote_rsa_signed_attrs_digest( + econtent_type, + econtent_der, + digest_algorithm, + )?; + let signed = self.sign_remote_digest(digest_algorithm, &prehash)?; + pkcs7::create_pkcs7_signed_data_der_with_rsa_signature( + econtent_type, + econtent_der, + digest_algorithm, + signed.signer_cert, + signed.chain, + &signed.signature, + content_mode, + ) + } + } + } + + fn sign_xml_signed_info( + &self, + algorithm: psign_opc_sign::vsix::VsixHashAlgorithm, + signed_info: &[u8], + ) -> Result<(Vec, Vec)> { + match self { + SigningProvider::Local(local) => { + let cert_der = local + .signer_cert + .to_der() + .context("encode signer cert DER")?; + let signature = + sign_xml_signed_info_rsa(algorithm, signed_info, &local.private_key)?; + Ok((signature, cert_der)) + } + #[cfg(feature = "azure-kv-sign")] + SigningProvider::AzureKeyVault(_) => { + let digest_algorithm = authenticode_digest_from_vsix_algorithm(algorithm); + let prehash = algorithm.hash(signed_info); + let signed = self.sign_remote_digest(digest_algorithm, &prehash)?; + let cert_der = signed + .signer_cert + .to_der() + .context("encode signer cert DER")?; + Ok((signed.signature, cert_der)) + } + #[cfg(feature = "artifact-signing-rest")] + SigningProvider::ArtifactSigning(_) => { + let digest_algorithm = authenticode_digest_from_vsix_algorithm(algorithm); + let prehash = algorithm.hash(signed_info); + let signed = self.sign_remote_digest(digest_algorithm, &prehash)?; + let cert_der = signed + .signer_cert + .to_der() + .context("encode signer cert DER")?; + Ok((signed.signature, cert_der)) + } + } + } + + #[cfg(any(feature = "azure-kv-sign", feature = "artifact-signing-rest"))] + fn sign_remote_digest( + &self, + digest_algorithm: AuthenticodeSigningDigest, + digest: &[u8], + ) -> Result { + match self { + SigningProvider::Local(_) => { + bail!("internal error: local signing provider cannot remote-sign a digest") + } + #[cfg(feature = "azure-kv-sign")] + SigningProvider::AzureKeyVault(provider) => { + let signature = psign_azure_kv_rest::kv_sign_digest_from_certificate( + &provider.http, + &provider.token, + &provider.key_vault_certificate, + kv_hash_algorithm(digest_algorithm), + digest, + )?; + Ok(RemoteSignature { + signature, + signer_cert: provider.signer_cert.clone(), + chain: provider.chain.clone(), + }) + } + #[cfg(feature = "artifact-signing-rest")] + SigningProvider::ArtifactSigning(provider) => { + let params = psign_codesigning_rest::CodesigningSubmitParams { + region: "unused".to_string(), + account_name: provider.account_name.clone(), + profile_name: provider.profile_name.clone(), + digest: digest.to_vec(), + signature_algorithm: artifact_signature_algorithm(digest_algorithm).to_string(), + api_version: psign_codesigning_rest::DEFAULT_API_VERSION.to_string(), + correlation_id: None, + authority: None, + auth: provider.auth.clone(), + endpoint_base_url: Some(provider.endpoint.clone()), + }; + let debug_portable = std::env::var_os("SIGNTOOL_PORTABLE_DEBUG").is_some(); + let signed = psign_codesigning_rest::submit_codesign_hash_signature_blocking( + ¶ms, + |msg| { + if debug_portable { + eprintln!("[debug] {msg}"); + } + }, + )?; + let (signer_cert, mut returned_chain) = + pkcs7::parse_artifact_signing_certificates(&signed.signing_certificate)?; + returned_chain.extend(provider.chain.clone()); + Ok(RemoteSignature { + signature: signed.signature, + signer_cert, + chain: returned_chain, + }) + } + } + } +} + +fn load_signing_provider(request: &PortableSignRequest) -> Result { + validate_signing_provider_selection(request)?; + if has_azure_key_vault_provider(request) { + return load_azure_key_vault_signing_provider(request); + } + if has_artifact_signing_provider(request) { + return load_artifact_signing_provider(request); + } let (signer_cert, private_key, chain) = load_signing_material(request)?; - let pkcs7 = pkcs7::create_pe_authenticode_pkcs7_der_rsa( - &pe, - request.hash_algorithm.into(), + Ok(SigningProvider::Local(Box::new(LocalSigningProvider { signer_cert, - chain, private_key, + chain, + }))) +} + +#[cfg(feature = "azure-kv-sign")] +fn load_azure_key_vault_signing_provider(request: &PortableSignRequest) -> Result { + use std::time::Duration; + + let vault_url = text_opt(request.azure_key_vault_url.as_deref()) + .ok_or_else(|| anyhow::anyhow!("Azure Key Vault signing requires azure_key_vault_url"))?; + let certificate = + text_opt(request.azure_key_vault_certificate.as_deref()).ok_or_else(|| { + anyhow::anyhow!("Azure Key Vault signing requires azure_key_vault_certificate") + })?; + validate_kv_auth_inputs(request)?; + + let http = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .map_err(|e| anyhow::anyhow!("HTTP client: {e}"))?; + let auth = psign_azure_kv_rest::KvAuthParams { + access_token: request.azure_key_vault_access_token.as_deref(), + managed_identity: request.azure_key_vault_managed_identity.unwrap_or(false), + tenant_id: request.azure_key_vault_tenant_id.as_deref(), + client_id: request.azure_key_vault_client_id.as_deref(), + client_secret: request.azure_key_vault_client_secret.as_deref(), + authority: None, + }; + let token = psign_azure_kv_rest::acquire_kv_access_token(&auth)?; + let key_vault_certificate = + psign_azure_kv_rest::fetch_kv_certificate(&http, &vault_url, &certificate, None, &token)?; + let signer_cert_der = psign_azure_kv_rest::kv_decode_cer_b64(&key_vault_certificate.cer)?; + let signer_cert = + rdp::parse_certificate(&signer_cert_der).context("parse Key Vault signer certificate")?; + let chain = load_chain_certificates(request)?; + Ok(SigningProvider::AzureKeyVault(Box::new( + AzureKeyVaultSigningProvider { + http, + token, + key_vault_certificate, + signer_cert, + chain, + }, + ))) +} + +#[cfg(not(feature = "azure-kv-sign"))] +fn load_azure_key_vault_signing_provider( + _request: &PortableSignRequest, +) -> Result { + bail!( + "Azure Key Vault signing support is not compiled into this build (feature: azure-kv-sign)" ) - .with_context(|| { - format!( - "create portable PE Authenticode signature for {}", - request.path.display() - ) - })?; +} + +#[cfg(feature = "artifact-signing-rest")] +fn load_artifact_signing_provider(request: &PortableSignRequest) -> Result { + let endpoint = text_opt(request.artifact_signing_endpoint.as_deref()) + .ok_or_else(|| anyhow::anyhow!("Artifact Signing requires artifact_signing_endpoint"))?; + let account_name = + text_opt(request.artifact_signing_account_name.as_deref()).ok_or_else(|| { + anyhow::anyhow!("Artifact Signing requires artifact_signing_account_name") + })?; + let profile_name = + text_opt(request.artifact_signing_profile_name.as_deref()).ok_or_else(|| { + anyhow::anyhow!("Artifact Signing requires artifact_signing_profile_name") + })?; + let auth = artifact_signing_auth(request)?; + let chain = load_chain_certificates(request)?; + Ok(SigningProvider::ArtifactSigning(Box::new( + ArtifactSigningProvider { + endpoint: endpoint.trim_end_matches('/').to_string(), + account_name, + profile_name, + auth, + chain, + }, + ))) +} + +#[cfg(not(feature = "artifact-signing-rest"))] +fn load_artifact_signing_provider(_request: &PortableSignRequest) -> Result { + bail!( + "Artifact Signing support is not compiled into this build (feature: artifact-signing-rest)" + ) +} + +fn validate_signing_provider_selection(request: &PortableSignRequest) -> Result<()> { + let has_akv = has_azure_key_vault_provider(request); + let has_as = has_artifact_signing_provider(request); + let has_local = has_local_signing_material(request); + + if has_akv && has_as { + bail!( + "provide only one cloud signing provider (Azure Key Vault or Artifact Signing), not both" + ); + } + if has_akv && has_local { + bail!( + "provide either Azure Key Vault cloud signing or local certificate/key material, not both" + ); + } + if has_as && has_local { + bail!( + "provide either Artifact Signing cloud signing or local certificate/key material, not both" + ); + } + Ok(()) +} + +fn ensure_local_signing_provider(request: &PortableSignRequest) -> Result<()> { + validate_signing_provider_selection(request)?; + if has_azure_key_vault_provider(request) { + bail!("Azure Key Vault cloud signing is not wired for this portable signing format yet"); + } + if has_artifact_signing_provider(request) { + bail!("Artifact Signing cloud signing is not wired for this portable signing format yet"); + } + Ok(()) +} + +#[cfg(any(feature = "azure-kv-sign", feature = "artifact-signing-rest"))] +fn text_opt(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) +} + +#[cfg(feature = "azure-kv-sign")] +fn validate_kv_auth_inputs(request: &PortableSignRequest) -> Result<()> { + let has_sp = text_opt(request.azure_key_vault_client_secret.as_deref()).is_some(); + let has_tenant = text_opt(request.azure_key_vault_tenant_id.as_deref()).is_some(); + let has_client = text_opt(request.azure_key_vault_client_id.as_deref()).is_some(); + let has_token = text_opt(request.azure_key_vault_access_token.as_deref()).is_some(); + let managed_identity = request.azure_key_vault_managed_identity.unwrap_or(false); + + let sp_count = has_sp as u8 + has_tenant as u8 + has_client as u8; + if sp_count != 0 && sp_count != 3 { + bail!( + "Azure AD client credentials require azure_key_vault_client_id, azure_key_vault_client_secret, and azure_key_vault_tenant_id" + ); + } + if has_token && (managed_identity || sp_count == 3) { + bail!( + "use either Azure Key Vault access token or managed identity / client credentials, not multiple" + ); + } + if managed_identity && (has_token || sp_count == 3) { + bail!( + "Azure Key Vault managed identity cannot be combined with access tokens or client secrets" + ); + } + if !has_token && !managed_identity && sp_count != 3 { + bail!( + "choose Azure Key Vault authentication: access token, managed identity, or client id/secret/tenant" + ); + } + Ok(()) +} + +#[cfg(feature = "artifact-signing-rest")] +fn artifact_signing_auth( + request: &PortableSignRequest, +) -> Result { + let has_token = text_opt(request.artifact_signing_access_token.as_deref()).is_some(); + let tenant = text_opt(request.artifact_signing_tenant_id.as_deref()); + let client = text_opt(request.artifact_signing_client_id.as_deref()); + let secret = text_opt(request.artifact_signing_client_secret.as_deref()); + let managed_identity = request.artifact_signing_managed_identity.unwrap_or(false); + let sp_count = tenant.is_some() as u8 + client.is_some() as u8 + secret.is_some() as u8; + + if managed_identity { + if has_token || sp_count != 0 { + bail!( + "use either Artifact Signing managed identity, access token, or client credentials, not multiple" + ); + } + return Ok(psign_codesigning_rest::CodesigningAuth::ManagedIdentity); + } + if let Some(token) = text_opt(request.artifact_signing_access_token.as_deref()) { + if sp_count != 0 { + bail!("use either Artifact Signing access token or client credentials, not both"); + } + return Ok(psign_codesigning_rest::CodesigningAuth::Bearer(token)); + } + if sp_count != 0 && sp_count != 3 { + bail!( + "Artifact Signing client credentials require artifact_signing_tenant_id, artifact_signing_client_id, and artifact_signing_client_secret" + ); + } + if sp_count == 0 { + bail!( + "choose Artifact Signing authentication: managed identity, access token, or tenant/client-id/client-secret" + ); + } + Ok(psign_codesigning_rest::CodesigningAuth::ClientCredentials { + tenant_id: tenant.unwrap(), + client_id: client.unwrap(), + client_secret: secret.unwrap(), + }) +} + +#[cfg(feature = "azure-kv-sign")] +fn kv_hash_algorithm( + digest_algorithm: AuthenticodeSigningDigest, +) -> psign_azure_kv_rest::KvHashAlg { + match digest_algorithm { + AuthenticodeSigningDigest::Sha256 => psign_azure_kv_rest::KvHashAlg::Sha256, + AuthenticodeSigningDigest::Sha384 => psign_azure_kv_rest::KvHashAlg::Sha384, + AuthenticodeSigningDigest::Sha512 => psign_azure_kv_rest::KvHashAlg::Sha512, + } +} + +#[cfg(feature = "artifact-signing-rest")] +fn artifact_signature_algorithm(digest_algorithm: AuthenticodeSigningDigest) -> &'static str { + match digest_algorithm { + AuthenticodeSigningDigest::Sha256 => "RS256", + AuthenticodeSigningDigest::Sha384 => "RS384", + AuthenticodeSigningDigest::Sha512 => "RS512", + } +} + +#[cfg(any(feature = "azure-kv-sign", feature = "artifact-signing-rest"))] +fn authenticode_digest_from_vsix_algorithm( + algorithm: psign_opc_sign::vsix::VsixHashAlgorithm, +) -> AuthenticodeSigningDigest { + match algorithm { + psign_opc_sign::vsix::VsixHashAlgorithm::Sha256 => AuthenticodeSigningDigest::Sha256, + psign_opc_sign::vsix::VsixHashAlgorithm::Sha384 => AuthenticodeSigningDigest::Sha384, + psign_opc_sign::vsix::VsixHashAlgorithm::Sha512 => AuthenticodeSigningDigest::Sha512, + } +} + +fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result<()> { + let pe = + std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; + let provider = load_signing_provider(request)?; + let digest_algorithm: AuthenticodeSigningDigest = request.hash_algorithm.into(); + let pe_digest = pe_digest::pe_authenticode_digest(&pe, digest_algorithm.pe_hash_kind())?; + let indirect = pkcs7::pe_spc_indirect_data(digest_algorithm, &pe_digest)?; + let pkcs7 = provider + .create_authenticode_pkcs7(indirect, digest_algorithm) + .with_context(|| { + format!( + "create portable PE Authenticode signature for {}", + request.path.display() + ) + })?; let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) .with_context(|| format!("timestamp {}", request.path.display()))?; let signed = pe_embed::pe_append_authenticode_pkcs7_certificate(pe, &pkcs7) @@ -510,20 +1006,19 @@ fn sign_pe(request: &PortableSignRequest, output_path: &Path) -> Result<()> { fn sign_cab(request: &PortableSignRequest, output_path: &Path) -> Result<()> { let cab = std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; - let (signer_cert, private_key, chain) = load_signing_material(request)?; - let pkcs7 = pkcs7::create_cab_authenticode_pkcs7_der_rsa( - &cab, - request.hash_algorithm.into(), - signer_cert, - chain, - private_key, - ) - .with_context(|| { - format!( - "create portable CAB Authenticode signature for {}", - request.path.display() - ) - })?; + let provider = load_signing_provider(request)?; + let digest_algorithm: AuthenticodeSigningDigest = request.hash_algorithm.into(); + let cab_digest = + cab_digest::cab_authenticode_digest_for_signing(&cab, digest_algorithm.pe_hash_kind())?; + let indirect = pkcs7::cab_spc_indirect_data(digest_algorithm, &cab_digest)?; + let pkcs7 = provider + .create_authenticode_pkcs7(indirect, digest_algorithm) + .with_context(|| { + format!( + "create portable CAB Authenticode signature for {}", + request.path.display() + ) + })?; let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) .with_context(|| format!("timestamp {}", request.path.display()))?; let signed = cab_digest::cab_append_authenticode_pkcs7_signature(&cab, &pkcs7) @@ -534,20 +1029,19 @@ fn sign_cab(request: &PortableSignRequest, output_path: &Path) -> Result<()> { fn sign_msi(request: &PortableSignRequest, output_path: &Path) -> Result<()> { let msi = std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; - let (signer_cert, private_key, chain) = load_signing_material(request)?; - let pkcs7 = pkcs7::create_msi_authenticode_pkcs7_der_rsa( - &msi, - request.hash_algorithm.into(), - signer_cert, - chain, - private_key, - ) - .with_context(|| { - format!( - "create portable MSI Authenticode signature for {}", - request.path.display() - ) - })?; + let provider = load_signing_provider(request)?; + let digest_algorithm: AuthenticodeSigningDigest = request.hash_algorithm.into(); + let msi_digest = + msi_digest::compute_msi_authenticode_digest(&msi, digest_algorithm.pe_hash_kind())?; + let indirect = pkcs7::msi_spc_indirect_data(digest_algorithm, &msi_digest)?; + let pkcs7 = provider + .create_authenticode_pkcs7(indirect, digest_algorithm) + .with_context(|| { + format!( + "create portable MSI Authenticode signature for {}", + request.path.display() + ) + })?; let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) .with_context(|| format!("timestamp {}", request.path.display()))?; msi_digest::msi_embed_authenticode_pkcs7_signature(&request.path, output_path, &pkcs7) @@ -603,13 +1097,11 @@ fn sign_zip(request: &PortableSignRequest, output_path: &Path) -> Result<()> { ) })?; let script = zip_authenticode::unsigned_signature_script_bytes(&digest); - let (signer_cert, private_key, chain) = load_signing_material(request)?; - let pkcs7 = pkcs7::create_script_authenticode_pkcs7_der_rsa( + let provider = load_signing_provider(request)?; + let pkcs7 = create_script_authenticode_pkcs7_with_provider( + &provider, &script, request.hash_algorithm.into(), - signer_cert, - chain, - private_key, ) .with_context(|| { format!( @@ -633,7 +1125,7 @@ fn sign_zip(request: &PortableSignRequest, output_path: &Path) -> Result<()> { fn sign_nuget(request: &PortableSignRequest, output_path: &Path) -> Result<()> { let data = std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; - let (signer_cert, private_key, chain) = load_signing_material(request)?; + let provider = load_signing_provider(request)?; let nuget_alg = match request.hash_algorithm { PortableDigestAlgorithm::Sha256 => psign_opc_sign::nuget::NuGetHashAlgorithm::Sha256, PortableDigestAlgorithm::Sha384 => psign_opc_sign::nuget::NuGetHashAlgorithm::Sha384, @@ -651,20 +1143,14 @@ fn sign_nuget(request: &PortableSignRequest, output_path: &Path) -> Result<()> { // Create a CMS SignedData with id-data content type, then detach eContent let econtent_der = der_encode_octet_string(&content)?; let id_data = der::asn1::ObjectIdentifier::new_unwrap(pkcs7::PKCS7_ID_DATA_OID); - let pkcs7_bytes = pkcs7::create_pkcs7_signed_data_der_rsa( - id_data, - &econtent_der, - request.hash_algorithm.into(), - signer_cert, - chain, - private_key, - ) - .with_context(|| format!("create NuGet CMS signature for {}", request.path.display()))?; - // Detach eContent (NuGet signatures are detached CMS) - let mut sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7_bytes) - .context("parse generated CMS before detaching eContent")?; - sd.encap_content_info.econtent = None; - let pkcs7_detached = pkcs7::encode_pkcs7_content_info_signed_data_der(&sd)?; + let pkcs7_detached = provider + .create_pkcs7_signed_data( + id_data, + &econtent_der, + request.hash_algorithm.into(), + pkcs7::Pkcs7ContentMode::Detached, + ) + .with_context(|| format!("create NuGet CMS signature for {}", request.path.display()))?; let pkcs7_final = maybe_timestamp_pkcs7(request, pkcs7_detached) .with_context(|| format!("timestamp {}", request.path.display()))?; let mut out = Cursor::new(Vec::new()); @@ -677,7 +1163,7 @@ fn sign_nuget(request: &PortableSignRequest, output_path: &Path) -> Result<()> { fn sign_vsix(request: &PortableSignRequest, output_path: &Path) -> Result<()> { let data = std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; - let (signer_cert, private_key, chain) = load_signing_material(request)?; + let provider = load_signing_provider(request)?; let vsix_alg = match request.hash_algorithm { PortableDigestAlgorithm::Sha256 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha256, PortableDigestAlgorithm::Sha384 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha384, @@ -685,9 +1171,7 @@ fn sign_vsix(request: &PortableSignRequest, output_path: &Path) -> Result<()> { }; let signed_info = psign_opc_sign::vsix::signed_info_xml(Cursor::new(data.clone()), vsix_alg) .with_context(|| format!("create VSIX SignedInfo XML for {}", request.path.display()))?; - let cert_der = signer_cert.to_der().context("encode signer cert DER")?; - let signature = sign_xml_signed_info_rsa(vsix_alg, &signed_info, &private_key)?; - let _chain = chain; // chain included in KeyInfo is just the signer cert for VSIX + let (signature, cert_der) = provider.sign_xml_signed_info(vsix_alg, &signed_info)?; let xml = psign_opc_sign::vsix::signature_xml_from_signed_info( &signed_info, &signature, @@ -710,7 +1194,7 @@ fn sign_clickonce_manifest(request: &PortableSignRequest, output_path: &Path) -> request.path.display() ) })?; - let (signer_cert, private_key, _chain) = load_signing_material(request)?; + let provider = load_signing_provider(request)?; let vsix_alg = match request.hash_algorithm { PortableDigestAlgorithm::Sha256 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha256, PortableDigestAlgorithm::Sha384 => psign_opc_sign::vsix::VsixHashAlgorithm::Sha384, @@ -718,8 +1202,7 @@ fn sign_clickonce_manifest(request: &PortableSignRequest, output_path: &Path) -> }; let unsigned = remove_clickonce_xml_signature(text); let signed_info = clickonce_manifest_signed_info_xml_bytes(&unsigned, vsix_alg); - let cert_der = signer_cert.to_der().context("encode signer cert DER")?; - let signature = sign_xml_signed_info_rsa(vsix_alg, &signed_info, &private_key)?; + let (signature, cert_der) = provider.sign_xml_signed_info(vsix_alg, &signed_info)?; let signature_xml = build_clickonce_signature_xml(&signed_info, &signature, &cert_der); let signed = insert_clickonce_signature_in_manifest(&unsigned, &signature_xml)?; std::fs::write(output_path, signed.as_bytes()) @@ -729,29 +1212,23 @@ fn sign_clickonce_manifest(request: &PortableSignRequest, output_path: &Path) -> fn sign_appinstaller(request: &PortableSignRequest, output_path: &Path) -> Result<()> { let data = std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; - let (signer_cert, private_key, chain) = load_signing_material(request)?; + let provider = load_signing_provider(request)?; // Create a detached CMS over the descriptor content let econtent_der = der_encode_octet_string(&data)?; let id_data = der::asn1::ObjectIdentifier::new_unwrap(pkcs7::PKCS7_ID_DATA_OID); - let pkcs7_bytes = pkcs7::create_pkcs7_signed_data_der_rsa( - id_data, - &econtent_der, - request.hash_algorithm.into(), - signer_cert, - chain, - private_key, - ) - .with_context(|| { - format!( - "create detached PKCS#7 companion signature for {}", - request.path.display() + let pkcs7_detached = provider + .create_pkcs7_signed_data( + id_data, + &econtent_der, + request.hash_algorithm.into(), + pkcs7::Pkcs7ContentMode::Detached, ) - })?; - // Detach eContent - let mut sd = pkcs7::parse_pkcs7_signed_data_der(&pkcs7_bytes) - .context("parse generated CMS before detaching eContent")?; - sd.encap_content_info.econtent = None; - let pkcs7_detached = pkcs7::encode_pkcs7_content_info_signed_data_der(&sd)?; + .with_context(|| { + format!( + "create detached PKCS#7 companion signature for {}", + request.path.display() + ) + })?; let pkcs7_final = maybe_timestamp_pkcs7(request, pkcs7_detached) .with_context(|| format!("timestamp {}", request.path.display()))?; // Write the .p7 companion alongside the output descriptor @@ -870,13 +1347,11 @@ fn sign_script(request: &PortableSignRequest, output_path: &Path) -> Result<()> let script = std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; - let (signer_cert, private_key, chain) = load_signing_material(request)?; - let pkcs7 = pkcs7::create_script_authenticode_pkcs7_der_rsa( + let provider = load_signing_provider(request)?; + let pkcs7 = create_script_authenticode_pkcs7_with_provider( + &provider, &script, request.hash_algorithm.into(), - signer_cert, - chain, - private_key, ) .with_context(|| { format!( @@ -892,6 +1367,15 @@ fn sign_script(request: &PortableSignRequest, output_path: &Path) -> Result<()> std::fs::write(output_path, signed).with_context(|| format!("write {}", output_path.display())) } +fn create_script_authenticode_pkcs7_with_provider( + provider: &SigningProvider, + script: &[u8], + digest_algorithm: AuthenticodeSigningDigest, +) -> Result> { + let indirect = pkcs7::script_authenticode_spc_indirect_data(script, digest_algorithm)?; + provider.create_authenticode_pkcs7(indirect, digest_algorithm) +} + fn has_azure_key_vault_provider(request: &PortableSignRequest) -> bool { request.azure_key_vault_url.is_some() } @@ -915,54 +1399,7 @@ fn load_signing_material( rsa::RsaPrivateKey, Vec, )> { - // Reject mixed local + cloud providers - let has_akv = has_azure_key_vault_provider(request); - let has_as = has_artifact_signing_provider(request); - let has_local = has_local_signing_material(request); - - if has_akv && has_as { - bail!( - "provide only one cloud signing provider (Azure Key Vault or Artifact Signing), not both" - ); - } - if has_akv && has_local { - bail!( - "provide either Azure Key Vault cloud signing or local certificate/key material, not both" - ); - } - if has_as && has_local { - bail!( - "provide either Artifact Signing cloud signing or local certificate/key material, not both" - ); - } - if has_akv { - #[cfg(feature = "azure-kv-sign")] - { - bail!( - "Azure Key Vault portable signing is not yet available through this API — use psign-tool code azure-key-vault" - ); - } - #[cfg(not(feature = "azure-kv-sign"))] - { - bail!( - "Azure Key Vault signing support is not compiled into this build (feature: azure-kv-sign)" - ); - } - } - if has_as { - #[cfg(feature = "artifact-signing-rest")] - { - bail!( - "Artifact Signing portable signing is not yet available through this API — use psign-tool code artifact-signing" - ); - } - #[cfg(not(feature = "artifact-signing-rest"))] - { - bail!( - "Artifact Signing support is not compiled into this build (feature: artifact-signing-rest)" - ); - } - } + ensure_local_signing_provider(request)?; let uses_pfx = request.pfx_path.is_some(); if uses_pfx @@ -1014,6 +1451,12 @@ fn load_signing_material( let signer_cert = rdp::parse_certificate(&cert_bytes).context("parse signer certificate")?; let private_key = rdp::parse_rsa_private_key(&key_bytes).context("parse RSA private key")?; + let chain = load_chain_certificates(request)?; + + Ok((signer_cert, private_key, chain)) +} + +fn load_chain_certificates(request: &PortableSignRequest) -> Result> { let mut chain = Vec::new(); for path in &request.chain_certificate_paths { let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?; @@ -1031,8 +1474,7 @@ fn load_signing_material( .with_context(|| format!("parse chain certificate {index}"))?, ); } - - Ok((signer_cert, private_key, chain)) + Ok(chain) } fn load_pfx_cert_and_key(bytes: &[u8], password: &str) -> Result<(Vec, Vec)> { diff --git a/crates/psign-sip-digest/src/pkcs7.rs b/crates/psign-sip-digest/src/pkcs7.rs index fcf09ed..06c6a62 100644 --- a/crates/psign-sip-digest/src/pkcs7.rs +++ b/crates/psign-sip-digest/src/pkcs7.rs @@ -404,12 +404,7 @@ pub fn create_script_authenticode_pkcs7_der_rsa( chain_certs: Vec, private_key: RsaPrivateKey, ) -> Result> { - let units = crate::ps_script::file_utf16_units(script); - let script_digest = crate::ps_script::hash_payload( - digest_algorithm.pe_hash_kind(), - &crate::ps_script::utf16le_bytes(&units), - )?; - let indirect = script_spc_indirect_data(digest_algorithm, &script_digest)?; + let indirect = script_authenticode_spc_indirect_data(script, digest_algorithm)?; create_authenticode_pkcs7_der_rsa( indirect, digest_algorithm, @@ -419,6 +414,19 @@ pub fn create_script_authenticode_pkcs7_der_rsa( ) } +/// Build the Authenticode `SpcIndirectDataContent` for a PowerShell-class script. +pub fn script_authenticode_spc_indirect_data( + script: &[u8], + digest_algorithm: AuthenticodeSigningDigest, +) -> Result { + let units = crate::ps_script::file_utf16_units(script); + let script_digest = crate::ps_script::hash_payload( + digest_algorithm.pe_hash_kind(), + &crate::ps_script::utf16le_bytes(&units), + )?; + script_spc_indirect_data(digest_algorithm, &script_digest) +} + /// Create PKCS#7 `ContentInfo(SignedData)` DER for an Authenticode `SpcIndirectDataContent`. pub fn create_authenticode_pkcs7_der_rsa( indirect: SpcIndirectDataContent, diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 54e619b..43e065c 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -86,7 +86,7 @@ pwsh -File .\PowerShell\package.ps1 -Configuration Release -NativeArtifactsRoot The native artifact root should contain directories such as `psign-core-win-x64`, `psign-core-linux-x64`, and `psign-core-osx-arm64`, each containing the packaged native library name for that RID. -Release packaging can also sign the staged module payload before creating the `.nupkg`. The release workflow Authenticode-signs the module manifest, root script module, format file, and managed assemblies, then verifies them through the built `Devolutions.Psign` module, while preserving the separately signed Windows native `psign-core.dll` artifacts imported from the native signing job. Azure Key Vault signing uses the workflow-built `psign-tool` because the module API does not yet expose the Key Vault signer directly. The release ZIP remains only a transport archive for those signed files; it is not signed as a custom ZIP Authenticode package. +Release packaging can also sign the staged module payload before creating the `.nupkg`. The release workflow Authenticode-signs the module manifest, root script module, format file, and managed assemblies with `Set-PsignSignature`, then verifies them through the built `Devolutions.Psign` module, while preserving the separately signed Windows native `psign-core.dll` artifacts imported from the native signing job. The release ZIP remains only a transport archive for those signed files; it is not signed as a custom ZIP Authenticode package. ## Migrating from built-in Authenticode cmdlets From 3a85f072c52af07ddd9c41ef843ddca82435bcb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 25 May 2026 11:51:59 -0400 Subject: [PATCH 5/6] Allow untrusted dry-run module signatures Treat NotTrusted as an intact Authenticode signature during module payload verification so test-environment Key Vault certificates can exercise the signing flow without requiring a trusted root on the runner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PowerShell/sign-module.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/PowerShell/sign-module.ps1 b/PowerShell/sign-module.ps1 index f2e7259..02c1b3c 100644 --- a/PowerShell/sign-module.ps1 +++ b/PowerShell/sign-module.ps1 @@ -97,9 +97,13 @@ function Assert-PsignModuleSignatures { foreach ($target in $Targets) { $signature = Get-PsignSignature -LiteralPath $target -ErrorAction Stop - if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + if ($signature.SignatureType -ne [System.Management.Automation.SignatureType]::Authenticode -or + $signature.Status -notin @( + [System.Management.Automation.SignatureStatus]::Valid, + [System.Management.Automation.SignatureStatus]::NotTrusted + )) { $relativePath = Resolve-Path -LiteralPath $target -Relative - throw "Expected valid Authenticode signature for '$relativePath', got '$($signature.Status)': $($signature.StatusMessage)" + throw "Expected intact Authenticode signature for '$relativePath', got '$($signature.Status)': $($signature.StatusMessage)" } } } From f16cb3d77d87bbe9b4b2ed65cafa08cea0fb0d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 25 May 2026 12:23:42 -0400 Subject: [PATCH 6/6] Complete artifact signing module paths Route MSIX signing through the shared portable signing provider so Artifact Signing and Key Vault cover that format too. Let the PowerShell module package signing helper use either Azure Key Vault or Artifact Signing parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PowerShell/package.ps1 | 24 ++++++ PowerShell/sign-module.ps1 | 115 +++++++++++++++++++++----- crates/psign-portable-core/src/lib.rs | 26 +++--- crates/psign-sip-digest/src/pkcs7.rs | 28 +++++-- docs/portable-powershell-module.md | 4 +- 5 files changed, 151 insertions(+), 46 deletions(-) diff --git a/PowerShell/package.ps1 b/PowerShell/package.ps1 index 77f0239..37ac800 100644 --- a/PowerShell/package.ps1 +++ b/PowerShell/package.ps1 @@ -22,6 +22,22 @@ param( [string] $AzureKeyVaultTenantId, + [string] $ArtifactSigningEndpoint, + + [string] $ArtifactSigningAccountName, + + [string] $ArtifactSigningProfileName, + + [string] $ArtifactSigningAccessToken, + + [switch] $ArtifactSigningManagedIdentity, + + [string] $ArtifactSigningTenantId, + + [string] $ArtifactSigningClientId, + + [string] $ArtifactSigningClientSecret, + [string] $TimestampServer, [ValidateSet('Sha256', 'Sha384', 'Sha512')] @@ -84,6 +100,14 @@ if ($SignModule) { -AzureKeyVaultClientId $AzureKeyVaultClientId ` -AzureKeyVaultClientSecret $AzureKeyVaultClientSecret ` -AzureKeyVaultTenantId $AzureKeyVaultTenantId ` + -ArtifactSigningEndpoint $ArtifactSigningEndpoint ` + -ArtifactSigningAccountName $ArtifactSigningAccountName ` + -ArtifactSigningProfileName $ArtifactSigningProfileName ` + -ArtifactSigningAccessToken $ArtifactSigningAccessToken ` + -ArtifactSigningManagedIdentity:$ArtifactSigningManagedIdentity ` + -ArtifactSigningTenantId $ArtifactSigningTenantId ` + -ArtifactSigningClientId $ArtifactSigningClientId ` + -ArtifactSigningClientSecret $ArtifactSigningClientSecret ` -TimestampServer $TimestampServer ` -HashAlgorithm $HashAlgorithm ` -TimestampHashAlgorithm $TimestampHashAlgorithm | Out-Host diff --git a/PowerShell/sign-module.ps1 b/PowerShell/sign-module.ps1 index 02c1b3c..50746c5 100644 --- a/PowerShell/sign-module.ps1 +++ b/PowerShell/sign-module.ps1 @@ -16,6 +16,22 @@ param( [string] $AzureKeyVaultTenantId, + [string] $ArtifactSigningEndpoint, + + [string] $ArtifactSigningAccountName, + + [string] $ArtifactSigningProfileName, + + [string] $ArtifactSigningAccessToken, + + [switch] $ArtifactSigningManagedIdentity, + + [string] $ArtifactSigningTenantId, + + [string] $ArtifactSigningClientId, + + [string] $ArtifactSigningClientSecret, + [string] $TimestampServer, [ValidateSet('Sha256', 'Sha384', 'Sha512')] @@ -54,39 +70,96 @@ function Get-PsignModuleSigningTargets { return $targets.ToArray() } -function Assert-PsignModuleSigningParameters { - foreach ($name in @( - 'AzureKeyVaultUrl', - 'AzureKeyVaultCertificate', - 'AzureKeyVaultClientId', - 'AzureKeyVaultClientSecret', - 'AzureKeyVaultTenantId', - 'TimestampServer' - )) { +function Test-TextValue { + param([string] $Value) + + return -not [string]::IsNullOrWhiteSpace($Value) +} + +function Assert-RequiredTextParameters { + param([string[]] $Names) + + foreach ($name in $Names) { if ([string]::IsNullOrWhiteSpace((Get-Variable -Name $name -ValueOnly))) { throw "$name is required when signing a PowerShell module release payload." } } } +function Assert-PsignModuleSigningParameters { + Assert-RequiredTextParameters -Names @('TimestampServer') + + $hasAzureKeyVault = (Test-TextValue $AzureKeyVaultUrl) -or + (Test-TextValue $AzureKeyVaultCertificate) + $hasArtifactSigning = (Test-TextValue $ArtifactSigningEndpoint) -or + (Test-TextValue $ArtifactSigningAccountName) -or + (Test-TextValue $ArtifactSigningProfileName) + + if ($hasAzureKeyVault -eq $hasArtifactSigning) { + throw "Provide exactly one cloud signing provider for the PowerShell module release payload: Azure Key Vault or Artifact Signing." + } + + if ($hasAzureKeyVault) { + Assert-RequiredTextParameters -Names @( + 'AzureKeyVaultUrl', + 'AzureKeyVaultCertificate', + 'AzureKeyVaultClientId', + 'AzureKeyVaultClientSecret', + 'AzureKeyVaultTenantId' + ) + return + } + + Assert-RequiredTextParameters -Names @( + 'ArtifactSigningEndpoint', + 'ArtifactSigningAccountName', + 'ArtifactSigningProfileName' + ) +} + function Invoke-PsignModuleSigning { param( [Parameter(Mandatory = $true)] [string] $Target ) - Set-PsignSignature ` - -LiteralPath $Target ` - -AzureKeyVaultUrl $AzureKeyVaultUrl ` - -AzureKeyVaultCertificate $AzureKeyVaultCertificate ` - -AzureKeyVaultClientId $AzureKeyVaultClientId ` - -AzureKeyVaultClientSecret $AzureKeyVaultClientSecret ` - -AzureKeyVaultTenantId $AzureKeyVaultTenantId ` - -TimestampServer $TimestampServer ` - -TimestampHashAlgorithm $TimestampHashAlgorithm ` - -HashAlgorithm $HashAlgorithm ` - -Force ` - -ErrorAction Stop | Out-Null + $signArgs = @{ + LiteralPath = $Target + TimestampServer = $TimestampServer + TimestampHashAlgorithm = $TimestampHashAlgorithm + HashAlgorithm = $HashAlgorithm + Force = $true + ErrorAction = 'Stop' + } + + if (Test-TextValue $AzureKeyVaultUrl) { + $signArgs.AzureKeyVaultUrl = $AzureKeyVaultUrl + $signArgs.AzureKeyVaultCertificate = $AzureKeyVaultCertificate + $signArgs.AzureKeyVaultClientId = $AzureKeyVaultClientId + $signArgs.AzureKeyVaultClientSecret = $AzureKeyVaultClientSecret + $signArgs.AzureKeyVaultTenantId = $AzureKeyVaultTenantId + } else { + $signArgs.ArtifactSigningEndpoint = $ArtifactSigningEndpoint + $signArgs.ArtifactSigningAccountName = $ArtifactSigningAccountName + $signArgs.ArtifactSigningProfileName = $ArtifactSigningProfileName + if (Test-TextValue $ArtifactSigningAccessToken) { + $signArgs.ArtifactSigningAccessToken = $ArtifactSigningAccessToken + } + if ($ArtifactSigningManagedIdentity.IsPresent) { + $signArgs.ArtifactSigningManagedIdentity = $true + } + if (Test-TextValue $ArtifactSigningTenantId) { + $signArgs.ArtifactSigningTenantId = $ArtifactSigningTenantId + } + if (Test-TextValue $ArtifactSigningClientId) { + $signArgs.ArtifactSigningClientId = $ArtifactSigningClientId + } + if (Test-TextValue $ArtifactSigningClientSecret) { + $signArgs.ArtifactSigningClientSecret = $ArtifactSigningClientSecret + } + } + + Set-PsignSignature @signArgs | Out-Null } function Assert-PsignModuleSignatures { diff --git a/crates/psign-portable-core/src/lib.rs b/crates/psign-portable-core/src/lib.rs index 9f7df69..115c92f 100644 --- a/crates/psign-portable-core/src/lib.rs +++ b/crates/psign-portable-core/src/lib.rs @@ -1063,21 +1063,17 @@ fn sign_msix(request: &PortableSignRequest, output_path: &Path) -> Result<()> { std::fs::read(&request.path).with_context(|| format!("read {}", request.path.display()))?; let staged = stage_flat_msix_for_signature(&package, request.hash_algorithm) .with_context(|| format!("stage {} for MSIX signing", request.path.display()))?; - let (signer_cert, private_key, chain) = load_signing_material(request)?; - let pkcs7 = pkcs7::create_msix_authenticode_pkcs7_der_rsa( - &staged, - &ext, - request.hash_algorithm.into(), - signer_cert, - chain, - private_key, - ) - .with_context(|| { - format!( - "create portable MSIX Authenticode signature for {}", - request.path.display() - ) - })?; + let provider = load_signing_provider(request)?; + let digest_algorithm = request.hash_algorithm.into(); + let indirect = pkcs7::msix_spc_indirect_data(&staged, &ext, digest_algorithm)?; + let pkcs7 = provider + .create_authenticode_pkcs7(indirect, digest_algorithm) + .with_context(|| { + format!( + "create portable MSIX Authenticode signature for {}", + request.path.display() + ) + })?; let pkcs7 = maybe_timestamp_pkcs7(request, pkcs7) .with_context(|| format!("timestamp {}", request.path.display()))?; let mut p7x = b"PKCX".to_vec(); diff --git a/crates/psign-sip-digest/src/pkcs7.rs b/crates/psign-sip-digest/src/pkcs7.rs index 06c6a62..2463e45 100644 --- a/crates/psign-sip-digest/src/pkcs7.rs +++ b/crates/psign-sip-digest/src/pkcs7.rs @@ -343,6 +343,23 @@ pub fn create_msix_authenticode_pkcs7_der_rsa( chain_certs: Vec, private_key: RsaPrivateKey, ) -> Result> { + let indirect = + msix_spc_indirect_data(package_with_signature_part, extension, digest_algorithm)?; + create_authenticode_pkcs7_der_rsa( + indirect, + digest_algorithm, + signer_cert, + chain_certs, + private_key, + ) +} + +/// Build the Authenticode `SpcIndirectDataContent` for a cleartext MSIX / APPX package. +pub fn msix_spc_indirect_data( + package_with_signature_part: &[u8], + extension: &str, + digest_algorithm: AuthenticodeSigningDigest, +) -> Result { let (kind, appx_blob) = crate::msix_digest::msix_authenticode_digest_blob(package_with_signature_part, extension)?; if kind != digest_algorithm.pe_hash_kind() { @@ -352,7 +369,7 @@ pub fn create_msix_authenticode_pkcs7_der_rsa( digest_algorithm )); } - let indirect = SpcIndirectDataContent { + Ok(SpcIndirectDataContent { data: SpcAttributeTypeAndOptionalValue { value_type: SPC_MSI_SIGINFO_OBJID, value: Any::from_der(SPC_MSI_SIGINFO_VALUE_DER) @@ -363,14 +380,7 @@ pub fn create_msix_authenticode_pkcs7_der_rsa( digest: OctetString::new(appx_blob) .map_err(|e| anyhow!("APPX SpcIndirectData digest OCTET STRING: {e}"))?, }, - }; - create_authenticode_pkcs7_der_rsa( - indirect, - digest_algorithm, - signer_cert, - chain_certs, - private_key, - ) + }) } /// Create PKCS#7 `ContentInfo(SignedData)` DER for a PE Authenticode signature using an RSA private key. diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index 43e065c..a061f06 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -33,6 +33,8 @@ The portable cert store follows the same layout as `psign-tool cert-store`: `