Skip to content

Commit dc0ce67

Browse files
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>
1 parent aa36d81 commit dc0ce67

4 files changed

Lines changed: 262 additions & 9 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,7 @@ jobs:
659659
needs:
660660
- preflight
661661
- sign_windows
662+
environment: ${{ needs.preflight.outputs.publish_env }}
662663

663664
steps:
664665
- name: Checkout
@@ -677,10 +678,57 @@ jobs:
677678
with:
678679
dotnet-version: 8.0.x
679680

681+
- name: Resolve PowerShell module signing mode
682+
id: module_signing_mode
683+
shell: pwsh
684+
env:
685+
CODE_SIGNING_KEYVAULT_URL: ${{ secrets.CODE_SIGNING_KEYVAULT_URL }}
686+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
687+
CODE_SIGNING_CLIENT_ID: ${{ secrets.CODE_SIGNING_CLIENT_ID }}
688+
CODE_SIGNING_CLIENT_SECRET: ${{ secrets.CODE_SIGNING_CLIENT_SECRET }}
689+
CODE_SIGNING_CERTIFICATE_NAME: ${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }}
690+
CODE_SIGNING_TIMESTAMP_SERVER: ${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}
691+
run: |
692+
$dryRun = [System.Boolean]::Parse('${{ needs.preflight.outputs.dry_run }}')
693+
$signDryRun = [System.Boolean]::Parse('${{ github.event.inputs.sign_dry_run }}')
694+
695+
$requiredValues = @(
696+
$env:CODE_SIGNING_KEYVAULT_URL,
697+
$env:AZURE_TENANT_ID,
698+
$env:CODE_SIGNING_CLIENT_ID,
699+
$env:CODE_SIGNING_CLIENT_SECRET,
700+
$env:CODE_SIGNING_CERTIFICATE_NAME,
701+
$env:CODE_SIGNING_TIMESTAMP_SERVER
702+
)
703+
704+
$hasSigningSecrets = $true
705+
foreach ($value in $requiredValues) {
706+
if ([string]::IsNullOrWhiteSpace($value)) {
707+
$hasSigningSecrets = $false
708+
break
709+
}
710+
}
711+
712+
$shouldSign = $hasSigningSecrets -and ((-not $dryRun) -or $signDryRun)
713+
714+
if (-not $dryRun -and -not $hasSigningSecrets) {
715+
throw 'Code signing secrets are required for non-dry-run release jobs.'
716+
}
717+
718+
"should_sign=$($shouldSign.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
719+
Write-Host "PowerShell module payload will be signed: $shouldSign"
720+
680721
- name: Package PowerShell module
681722
shell: pwsh
682723
env:
683724
RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }}
725+
SIGN_POWERSHELL_MODULE: ${{ steps.module_signing_mode.outputs.should_sign }}
726+
CODE_SIGNING_KEYVAULT_URL: ${{ secrets.CODE_SIGNING_KEYVAULT_URL }}
727+
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
728+
CODE_SIGNING_CLIENT_ID: ${{ secrets.CODE_SIGNING_CLIENT_ID }}
729+
CODE_SIGNING_CLIENT_SECRET: ${{ secrets.CODE_SIGNING_CLIENT_SECRET }}
730+
CODE_SIGNING_CERTIFICATE_NAME: ${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }}
731+
CODE_SIGNING_TIMESTAMP_SERVER: ${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}
684732
run: |
685733
$tag = $env:RELEASE_TAG
686734
if (-not $tag.StartsWith('v')) {
@@ -691,12 +739,25 @@ jobs:
691739
New-Item -ItemType Directory -Force -Path dist | Out-Null
692740
$archivePath = Join-Path 'dist' "Devolutions.Psign.$version.zip"
693741
694-
./PowerShell/package.ps1 `
695-
-Configuration Release `
696-
-NativeArtifactsRoot 'dist/native' `
697-
-SkipNativeBuild `
698-
-OutputDirectory 'dist/powershell' `
699-
-ModuleArchivePath $archivePath
742+
$packageArgs = @{
743+
Configuration = 'Release'
744+
NativeArtifactsRoot = 'dist/native'
745+
SkipNativeBuild = $true
746+
OutputDirectory = 'dist/powershell'
747+
ModuleArchivePath = $archivePath
748+
}
749+
750+
if ([System.Boolean]::Parse($env:SIGN_POWERSHELL_MODULE)) {
751+
$packageArgs.SignModule = $true
752+
$packageArgs.AzureKeyVaultUrl = $env:CODE_SIGNING_KEYVAULT_URL
753+
$packageArgs.AzureKeyVaultCertificate = $env:CODE_SIGNING_CERTIFICATE_NAME
754+
$packageArgs.AzureKeyVaultClientId = $env:CODE_SIGNING_CLIENT_ID
755+
$packageArgs.AzureKeyVaultClientSecret = $env:CODE_SIGNING_CLIENT_SECRET
756+
$packageArgs.AzureKeyVaultTenantId = $env:AZURE_TENANT_ID
757+
$packageArgs.TimestampServer = $env:CODE_SIGNING_TIMESTAMP_SERVER
758+
}
759+
760+
./PowerShell/package.ps1 @packageArgs
700761
701762
- name: Upload PowerShell module package artifact
702763
uses: actions/upload-artifact@v7

‎PowerShell/package.ps1‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,27 @@ param(
88

99
[switch] $SkipNativeBuild,
1010

11-
[string] $ModuleArchivePath
11+
[string] $ModuleArchivePath,
12+
13+
[switch] $SignModule,
14+
15+
[string] $AzureKeyVaultUrl,
16+
17+
[string] $AzureKeyVaultCertificate,
18+
19+
[string] $AzureKeyVaultClientId,
20+
21+
[string] $AzureKeyVaultClientSecret,
22+
23+
[string] $AzureKeyVaultTenantId,
24+
25+
[string] $TimestampServer,
26+
27+
[ValidateSet('Sha256', 'Sha384', 'Sha512')]
28+
[string] $HashAlgorithm = 'Sha256',
29+
30+
[ValidateSet('Sha1', 'Sha256', 'Sha384', 'Sha512')]
31+
[string] $TimestampHashAlgorithm = 'Sha256'
1232
)
1333

1434
$ErrorActionPreference = 'Stop'
@@ -17,6 +37,7 @@ $repo = Split-Path -Parent $PSScriptRoot
1737
$moduleRoot = Join-Path $PSScriptRoot 'Devolutions.Psign'
1838
$localRepo = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N'))
1939
$installRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N'))
40+
$stagingRoot = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString('N'))
2041
$repoName = "DevolutionsPsignLocal$([System.Guid]::NewGuid().ToString('N'))"
2142

2243
$buildArgs = @{
@@ -49,9 +70,36 @@ foreach ($cmdlet in $expectedCmdlets) {
4970
}
5071
}
5172

73+
$packageModuleRoot = $moduleRoot
74+
if ($SignModule) {
75+
New-Item -ItemType Directory -Force -Path $stagingRoot | Out-Null
76+
Copy-Item -LiteralPath $moduleRoot -Destination $stagingRoot -Recurse -Force
77+
$packageModuleRoot = Join-Path $stagingRoot (Split-Path -Leaf $moduleRoot)
78+
79+
& (Join-Path $PSScriptRoot 'sign-module.ps1') `
80+
-ModuleRoot $packageModuleRoot `
81+
-SignerModuleRoot $moduleRoot `
82+
-AzureKeyVaultUrl $AzureKeyVaultUrl `
83+
-AzureKeyVaultCertificate $AzureKeyVaultCertificate `
84+
-AzureKeyVaultClientId $AzureKeyVaultClientId `
85+
-AzureKeyVaultClientSecret $AzureKeyVaultClientSecret `
86+
-AzureKeyVaultTenantId $AzureKeyVaultTenantId `
87+
-TimestampServer $TimestampServer `
88+
-HashAlgorithm $HashAlgorithm `
89+
-TimestampHashAlgorithm $TimestampHashAlgorithm | Out-Host
90+
91+
$manifestPath = Join-Path $packageModuleRoot 'Devolutions.Psign.psd1'
92+
$manifest = Test-ModuleManifest -Path $manifestPath
93+
foreach ($cmdlet in $expectedCmdlets) {
94+
if ($manifest.ExportedCmdlets.Keys -notcontains $cmdlet) {
95+
throw "Signed module manifest does not export expected cmdlet '$cmdlet'."
96+
}
97+
}
98+
}
99+
52100
try {
53101
Register-PSRepository -Name $repoName -SourceLocation $localRepo -PublishLocation $localRepo -InstallationPolicy Trusted
54-
Publish-Module -Path $moduleRoot -Repository $repoName -NuGetApiKey 'local-package'
102+
Publish-Module -Path $packageModuleRoot -Repository $repoName -NuGetApiKey 'local-package'
55103

56104
$package = Get-ChildItem -Path $localRepo -Filter 'Devolutions.Psign.*.nupkg' |
57105
Sort-Object LastWriteTimeUtc -Descending |
@@ -69,6 +117,10 @@ try {
69117
throw "Installed package smoke test did not find cmdlet '$cmdlet'."
70118
}
71119
}
120+
if ($SignModule) {
121+
$savedModuleRoot = Split-Path -Parent $savedManifest
122+
& (Join-Path $PSScriptRoot 'sign-module.ps1') -ModuleRoot $savedModuleRoot -VerifyOnly | Out-Host
123+
}
72124
$nativeProbe = New-TemporaryFile
73125
try {
74126
$null = Get-PsignSignature -LiteralPath $nativeProbe.FullName -ErrorAction Stop
@@ -80,7 +132,7 @@ try {
80132
if (Test-Path -LiteralPath $ModuleArchivePath) {
81133
Remove-Item -LiteralPath $ModuleArchivePath -Force
82134
}
83-
Compress-Archive -Path $moduleRoot -DestinationPath $ModuleArchivePath -Force
135+
Compress-Archive -Path $packageModuleRoot -DestinationPath $ModuleArchivePath -Force
84136
}
85137
Get-Item -LiteralPath (Join-Path $OutputDirectory $package.Name)
86138
}
@@ -89,4 +141,5 @@ finally {
89141
Unregister-PSRepository -Name $repoName -ErrorAction SilentlyContinue
90142
Remove-Item -LiteralPath $localRepo -Recurse -Force -ErrorAction SilentlyContinue
91143
Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue
144+
Remove-Item -LiteralPath $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue
92145
}

‎PowerShell/sign-module.ps1‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
param(
2+
[Parameter(Mandatory = $true)]
3+
[string] $ModuleRoot,
4+
5+
[string] $SignerModuleRoot,
6+
7+
[switch] $VerifyOnly,
8+
9+
[string] $AzureKeyVaultUrl,
10+
11+
[string] $AzureKeyVaultCertificate,
12+
13+
[string] $AzureKeyVaultClientId,
14+
15+
[string] $AzureKeyVaultClientSecret,
16+
17+
[string] $AzureKeyVaultTenantId,
18+
19+
[string] $TimestampServer,
20+
21+
[ValidateSet('Sha256', 'Sha384', 'Sha512')]
22+
[string] $HashAlgorithm = 'Sha256',
23+
24+
[ValidateSet('Sha1', 'Sha256', 'Sha384', 'Sha512')]
25+
[string] $TimestampHashAlgorithm = 'Sha256'
26+
)
27+
28+
$ErrorActionPreference = 'Stop'
29+
30+
function Get-PsignModuleSigningTargets {
31+
param(
32+
[Parameter(Mandatory = $true)]
33+
[string] $ResolvedModuleRoot
34+
)
35+
36+
$topLevelExtensions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
37+
foreach ($extension in @('.psd1', '.psm1', '.ps1xml')) {
38+
[void] $topLevelExtensions.Add($extension)
39+
}
40+
41+
$targets = [System.Collections.Generic.List[string]]::new()
42+
Get-ChildItem -LiteralPath $ResolvedModuleRoot -File |
43+
Where-Object { $topLevelExtensions.Contains($_.Extension) } |
44+
Sort-Object FullName |
45+
ForEach-Object { [void] $targets.Add($_.FullName) }
46+
47+
$managedAssemblyRoot = Join-Path (Join-Path $ResolvedModuleRoot 'lib') 'net8.0'
48+
if (Test-Path -LiteralPath $managedAssemblyRoot) {
49+
Get-ChildItem -LiteralPath $managedAssemblyRoot -Filter '*.dll' -File |
50+
Sort-Object FullName |
51+
ForEach-Object { [void] $targets.Add($_.FullName) }
52+
}
53+
54+
return $targets.ToArray()
55+
}
56+
57+
function Assert-PsignModuleSigningParameters {
58+
foreach ($name in @(
59+
'AzureKeyVaultUrl',
60+
'AzureKeyVaultCertificate',
61+
'AzureKeyVaultClientId',
62+
'AzureKeyVaultClientSecret',
63+
'AzureKeyVaultTenantId',
64+
'TimestampServer'
65+
)) {
66+
if ([string]::IsNullOrWhiteSpace((Get-Variable -Name $name -ValueOnly))) {
67+
throw "$name is required when signing a PowerShell module release payload."
68+
}
69+
}
70+
}
71+
72+
function Assert-PsignModuleSignatures {
73+
param(
74+
[Parameter(Mandatory = $true)]
75+
[string[]] $Targets
76+
)
77+
78+
foreach ($target in $Targets) {
79+
$signature = Get-PsignSignature -LiteralPath $target -ErrorAction Stop
80+
if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) {
81+
$relativePath = Resolve-Path -LiteralPath $target -Relative
82+
throw "Expected valid Authenticode signature for '$relativePath', got '$($signature.Status)': $($signature.StatusMessage)"
83+
}
84+
}
85+
}
86+
87+
$resolvedModuleRoot = (Resolve-Path -LiteralPath $ModuleRoot).Path
88+
$manifestPath = Join-Path $resolvedModuleRoot 'Devolutions.Psign.psd1'
89+
if (-not (Test-Path -LiteralPath $manifestPath)) {
90+
throw "Devolutions.Psign manifest not found at '$manifestPath'."
91+
}
92+
93+
$resolvedSignerModuleRoot = if ([string]::IsNullOrWhiteSpace($SignerModuleRoot)) {
94+
$resolvedModuleRoot
95+
} else {
96+
(Resolve-Path -LiteralPath $SignerModuleRoot).Path
97+
}
98+
$signerManifestPath = Join-Path $resolvedSignerModuleRoot 'Devolutions.Psign.psd1'
99+
if (-not (Test-Path -LiteralPath $signerManifestPath)) {
100+
throw "Devolutions.Psign signer manifest not found at '$signerManifestPath'."
101+
}
102+
103+
$targets = @(Get-PsignModuleSigningTargets -ResolvedModuleRoot $resolvedModuleRoot)
104+
if ($targets.Count -eq 0) {
105+
throw "No PowerShell module signing targets were found under '$resolvedModuleRoot'."
106+
}
107+
108+
Import-Module $signerManifestPath -Force -ErrorAction Stop
109+
110+
if (-not $VerifyOnly.IsPresent) {
111+
Assert-PsignModuleSigningParameters
112+
113+
$signingArguments = @{
114+
AzureKeyVaultUrl = $AzureKeyVaultUrl
115+
AzureKeyVaultCertificate = $AzureKeyVaultCertificate
116+
AzureKeyVaultClientId = $AzureKeyVaultClientId
117+
AzureKeyVaultClientSecret = $AzureKeyVaultClientSecret
118+
AzureKeyVaultTenantId = $AzureKeyVaultTenantId
119+
TimestampServer = $TimestampServer
120+
HashAlgorithm = $HashAlgorithm
121+
TimestampHashAlgorithm = $TimestampHashAlgorithm
122+
Force = $true
123+
}
124+
125+
foreach ($target in $targets) {
126+
Set-PsignSignature -LiteralPath $target @signingArguments -ErrorAction Stop | Out-Null
127+
}
128+
}
129+
130+
Assert-PsignModuleSignatures -Targets $targets
131+
132+
[pscustomobject]@{
133+
ModuleRoot = $resolvedModuleRoot
134+
Signed = -not $VerifyOnly.IsPresent
135+
TargetCount = $targets.Count
136+
Targets = $targets
137+
}

‎docs/portable-powershell-module.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ pwsh -File .\PowerShell\package.ps1 -Configuration Release -NativeArtifactsRoot
8686

8787
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.
8888

89+
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.
90+
8991
## Migrating from built-in Authenticode cmdlets
9092

9193
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:

0 commit comments

Comments
 (0)