From 5225f9bbf02df11eeb6c0347d9913e3651a714e7 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:12:50 -0400 Subject: [PATCH 1/4] refactor: Load PFX certificates through a private function The EnvVar and PfxFile sources construct an X509Certificate2 directly. A constructor is a .NET call rather than a command, so nothing could stand in for it, and the validation Get-PSBuildCertificate applies afterwards -- private key, expiry, and the Code Signing extended key usage -- could only ever run against a certificate the machine happened to have. It never ran at all. Import-PSBuildX509Certificate names that load as a command and changes nothing else: the same two constructor overloads, the same exceptions left to propagate, and the password parameter supplied only when the environment variable held something, because no password and an empty one are not the same thing to the PFX loader. Refs psake/PowerShellBuild#216 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- .../Private/Import-PSBuildX509Certificate.ps1 | 68 +++++++++++++++++++ .../Public/Get-PSBuildCertificate.ps1 | 12 +++- 2 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 PowerShellBuild/Private/Import-PSBuildX509Certificate.ps1 diff --git a/PowerShellBuild/Private/Import-PSBuildX509Certificate.ps1 b/PowerShellBuild/Private/Import-PSBuildX509Certificate.ps1 new file mode 100644 index 0000000..7bdb3eb --- /dev/null +++ b/PowerShellBuild/Private/Import-PSBuildX509Certificate.ps1 @@ -0,0 +1,68 @@ +function Import-PSBuildX509Certificate { + <# + .SYNOPSIS + Construct an X509Certificate2 from raw PFX bytes or from a PFX file on disk. + .DESCRIPTION + Get-PSBuildCertificate loads a certificate from two places that are not the Windows + certificate store: a Base64 payload held in an environment variable, and a PFX file on + disk. Both go through the X509Certificate2 constructor, and a constructor is a .NET call + rather than a command, so nothing downstream of it could be tested without a certificate + that the running machine happened to have. + + Naming the load as a command gives it a seam. With the load replaced, the validation + Get-PSBuildCertificate applies afterwards -- private key, expiry, and the Code Signing + extended key usage -- can be driven against a certificate of the caller's choosing on any + platform. That validation had never executed before psake/PowerShellBuild#216. + + No validation happens here, and nothing is written to a certificate store. Whatever the + constructor throws is left to propagate: its message about malformed input is more + specific than anything this function could add. + .PARAMETER RawData + The decoded PFX bytes to construct the certificate from. + .PARAMETER Password + Password protecting the PFX bytes. Omit it when the payload carries no password; an + empty string and no password at all are not the same thing to the loader. + .PARAMETER FilePath + Path to the PFX or P12 file to construct the certificate from. + .PARAMETER FilePassword + Password protecting the PFX file, as a SecureString. Omit it when the file carries no + password. + .EXAMPLE + PS> Import-PSBuildX509Certificate -RawData $decodedBytes -Password $password + + Construct a certificate from the bytes of a Base64-encoded PFX held in a CI secret. + .EXAMPLE + PS> Import-PSBuildX509Certificate -FilePath ./signing-certificate.pfx -FilePassword $securePassword + + Construct a certificate from a PFX file on disk. + .OUTPUTS + System.Security.Cryptography.X509Certificates.X509Certificate2 + #> + [CmdletBinding(DefaultParameterSetName = 'RawData')] + [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingPlainTextForPassword', + 'Password', + Justification = 'The X509Certificate2 overload that takes raw bytes takes the password as a string, and the caller reads it from an environment variable that is already a string.' + )] + param( + [Parameter(Mandatory, ParameterSetName = 'RawData')] + [byte[]]$RawData, + + [Parameter(ParameterSetName = 'RawData')] + [AllowEmptyString()] + [string]$Password, + + [Parameter(Mandatory, ParameterSetName = 'FilePath')] + [string]$FilePath, + + [Parameter(ParameterSetName = 'FilePath')] + [securestring]$FilePassword + ) + + if ($PSCmdlet.ParameterSetName -eq 'RawData') { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($RawData, $Password) + } else { + [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($FilePath, $FilePassword) + } +} diff --git a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 index 69cab6e..9a6cc62 100644 --- a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 +++ b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 @@ -215,11 +215,19 @@ throw "Environment variable '$CertificateEnvVar' does not contain a valid Base64-encoded PFX value." } $password = [System.Environment]::GetEnvironmentVariable($CertificatePasswordEnvVar) - $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($buffer, $password) + + # GetEnvironmentVariable returns $null when the variable is not set, and to the PFX + # loader no password at all is not the same thing as an empty one, so the parameter + # is supplied only when the variable held something. + $certificateParameter = @{ RawData = $buffer } + if ($null -ne $password) { + $certificateParameter['Password'] = $password + } + $cert = Import-PSBuildX509Certificate @certificateParameter Write-Verbose ($LocalizedData.CertificateResolvedFromEnvVar -f $CertificateEnvVar) } 'PfxFile' { - $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($PfxFilePath, $PfxFilePassword) + $cert = Import-PSBuildX509Certificate -FilePath $PfxFilePath -FilePassword $PfxFilePassword Write-Verbose ($LocalizedData.CertificateResolvedFromPfxFile -f $PfxFilePath) } } From f7cf3858ffcbf280d26eaa09f5ed3f5dd9b723d7 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:13:01 -0400 Subject: [PATCH 2/4] test: Scope the certificate mocks to the module and cover the validation Six Mock Get-ChildItem statements were declared without -ModuleName, so they never reached the call Get-PSBuildCertificate makes from inside the module. Three of the tests they backed asserted the real certificate store was empty, which is true on CI and on any workstation without a code-signing certificate, and false on a maintainer's machine -- the one place signing is worked on. The mocks are now module-scoped, and the three store tests return an expired certificate, a certificate without a private key, and a certificate whose thumbprint was not the one asked for, so each one exercises the filter it is named after. Two tests are added for the case none of them covered: a store that does hold a usable certificate. The post-load validation block is covered for the first time, through objects carrying the five properties the function reads. Generated certificates cannot do this job: EnhancedKeyUsageList comes from PowerShell's own type data, and one and the same generated code-signing certificate reports the Code Signing usage on Windows PowerShell 5.1 and an empty list on PowerShell 7. Three guard clauses that had never run are covered too: the empty thumbprint, the unset environment variable, and the Store source's refusal to run where there is no certificate store, which runs on the Linux and macOS legs. Closes psake/PowerShellBuild#216 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- tests/Get-PSBuildCertificate.tests.ps1 | 298 ++++++++++++++++++++++--- 1 file changed, 261 insertions(+), 37 deletions(-) diff --git a/tests/Get-PSBuildCertificate.tests.ps1 b/tests/Get-PSBuildCertificate.tests.ps1 index 9f889c9..3bcad98 100644 --- a/tests/Get-PSBuildCertificate.tests.ps1 +++ b/tests/Get-PSBuildCertificate.tests.ps1 @@ -13,6 +13,40 @@ Describe 'Code Signing Functions' { Context 'Get-PSBuildCertificate' { + BeforeAll { + # Get-PSBuildCertificate reads five properties off a certificate: Subject, Thumbprint, + # HasPrivateKey, NotAfter, and EnhancedKeyUsageList. Objects carrying those properties + # drive the real selection and validation branches on every platform and every edition, + # which a generated certificate cannot do: EnhancedKeyUsageList is contributed by + # PowerShell's own type data, and one and the same generated code-signing certificate + # reports the Code Signing usage on Windows PowerShell 5.1 and an empty list on + # PowerShell 7. + # + # Mocks that stand these in must be declared with -ModuleName, because a mock is scoped to + # the session state it is declared in and Get-PSBuildCertificate calls Get-ChildItem from + # inside the module. Six mocks in this file were declared without it, so they never reached + # the module and the tests below were asserting that the machine running them happened to + # have no code-signing certificate installed. See psake/PowerShellBuild#216. + $script:validCertificate = [PSCustomObject]@{ + Subject = 'CN=Valid Test Certificate' + Thumbprint = 'AAAA111122223333444455556666777788889999' + HasPrivateKey = $true + NotAfter = (Get-Date).AddDays(30) + } + $script:expiredCertificate = [PSCustomObject]@{ + Subject = 'CN=Expired Test Certificate' + Thumbprint = 'BBBB111122223333444455556666777788889999' + HasPrivateKey = $true + NotAfter = (Get-Date).AddDays(-1) + } + $script:noPrivateKeyCertificate = [PSCustomObject]@{ + Subject = 'CN=No Private Key Test Certificate' + Thumbprint = 'CCCC111122223333444455556666777788889999' + HasPrivateKey = $false + NotAfter = (Get-Date).AddDays(30) + } + } + BeforeEach { # Clear environment variables before each test Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue @@ -21,7 +55,7 @@ Describe 'Code Signing Functions' { Context 'Auto mode' { It 'Defaults to Auto mode when no CertificateSource is specified' -Skip:($null -ne $IsWindows -and -not $IsWindows) { - Mock Get-ChildItem {} + Mock -ModuleName PowerShellBuild -CommandName Get-ChildItem -MockWith { } $VerboseOutput = Get-PSBuildCertificate -Verbose -ErrorAction SilentlyContinue 4>&1 $VerboseOutput[0] | Should -Match "CertificateSource is 'Auto'" } @@ -39,7 +73,7 @@ Describe 'Code Signing Functions' { It 'Resolves to Store mode when SIGNCERTIFICATE environment variable is not set' -Skip:($null -ne $IsWindows -and -not $IsWindows) { Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue - Mock Get-ChildItem {} + Mock -ModuleName PowerShellBuild -CommandName Get-ChildItem -MockWith { } $VerboseOutput = Get-PSBuildCertificate -ErrorAction SilentlyContinue -Verbose *>&1 $VerboseOutput[0] | Should -Match ".*Resolved to 'Store'.*" } @@ -58,27 +92,54 @@ Describe 'Code Signing Functions' { } It 'Returns $null when no valid certificate is found' -Skip:($null -ne $IsWindows -and -not $IsWindows) { - Mock Get-ChildItem { } - $cert = Get-PSBuildCertificate -CertificateSource Store - $cert | Should -BeNullOrEmpty + Mock -ModuleName PowerShellBuild -CommandName Get-ChildItem -MockWith { } + + $certificate = Get-PSBuildCertificate -CertificateSource Store + + $certificate | Should -BeNullOrEmpty + # The store this test describes is the mocked one, not whatever the machine happens to + # hold. Asserting the module actually called the mock is what tells the two apart. + Should -Invoke -ModuleName PowerShellBuild -CommandName Get-ChildItem -Times 1 -Exactly } It 'Filters out expired certificates' -Skip:($null -ne $IsWindows -and -not $IsWindows) { - Mock Get-ChildItem { - # Return nothing (expired cert is filtered by Where-Object) + Mock -ModuleName PowerShellBuild -CommandName Get-ChildItem -MockWith { + $script:expiredCertificate } - $cert = Get-PSBuildCertificate -CertificateSource Store - $cert | Should -BeNullOrEmpty + $certificate = Get-PSBuildCertificate -CertificateSource Store + + $certificate | Should -BeNullOrEmpty } It 'Filters out certificates without a private key' -Skip:($null -ne $IsWindows -and -not $IsWindows) { - Mock Get-ChildItem { - # Return nothing (cert without private key is filtered by Where-Object) + Mock -ModuleName PowerShellBuild -CommandName Get-ChildItem -MockWith { + $script:noPrivateKeyCertificate } - $cert = Get-PSBuildCertificate -CertificateSource Store - $cert | Should -BeNullOrEmpty + $certificate = Get-PSBuildCertificate -CertificateSource Store + + $certificate | Should -BeNullOrEmpty + } + + It 'Returns a valid certificate that the store does hold' -Skip:($null -ne $IsWindows -and -not $IsWindows) { + # The counterpart of the three tests above: they only show that unusable certificates are + # rejected, which an unconditional $null would satisfy just as well. + Mock -ModuleName PowerShellBuild -CommandName Get-ChildItem -MockWith { + $script:validCertificate + } + + $certificate = Get-PSBuildCertificate -CertificateSource Store + + $certificate.Subject | Should -Be 'CN=Valid Test Certificate' + } + + It 'Throws where there is no certificate store to search' -Skip:($null -eq $IsWindows -or $IsWindows) { + # The mirror image of the guard on every other test in this context. The Store source is + # Windows-only by design, and on Linux and macOS it is expected to say so rather than + # fail obscurely inside the certificate provider. + { Get-PSBuildCertificate -CertificateSource Store } | + Should -Throw '*only supported on Windows*' } It 'Uses custom CertStoreLocation when specified' -Skip:($null -ne $IsWindows -and -not $IsWindows) { @@ -97,13 +158,43 @@ Describe 'Code Signing Functions' { } It 'Returns $null when the specified thumbprint is not found' -Skip:($null -ne $IsWindows -and -not $IsWindows) { - Mock Get-ChildItem { } - $cert = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'NOTFOUND123' - $cert | Should -BeNullOrEmpty + # The store holds a perfectly usable certificate; it is simply not the one that was + # asked for. Signing with it would sign with an identity the build never named. + Mock -ModuleName PowerShellBuild -CommandName Get-ChildItem -MockWith { + $script:validCertificate + } + + $certificate = Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint 'NOTFOUND123' + + $certificate | Should -BeNullOrEmpty + } + + It 'Throws when the thumbprint is empty' { + { Get-PSBuildCertificate -CertificateSource Thumbprint -Thumbprint ' ' } | + Should -Throw "*requires a non-empty Thumbprint value*" + } + + It 'Returns the certificate whose thumbprint was asked for' -Skip:($null -ne $IsWindows -and -not $IsWindows) { + Mock -ModuleName PowerShellBuild -CommandName Get-ChildItem -MockWith { + $script:noPrivateKeyCertificate + $script:validCertificate + } + + $certificate = Get-PSBuildCertificate -CertificateSource Thumbprint ` + -Thumbprint $script:validCertificate.Thumbprint + + $certificate.Thumbprint | Should -Be $script:validCertificate.Thumbprint } } Context 'EnvVar mode' { + It 'Throws when the environment variable holds nothing' { + # The BeforeEach above clears SIGNCERTIFICATE, which is the situation a consumer lands in + # when the CI secret was never wired up. + { Get-PSBuildCertificate -CertificateSource EnvVar } | + Should -Throw '*is not set or is empty*' + } + It 'Attempts to decode a Base64-encoded PFX from environment variable' { # Create a minimal mock certificate data (will fail to parse, but that's expected) $env:SIGNCERTIFICATE = [System.Convert]::ToBase64String([byte[]]@(1, 2, 3, 4, 5)) @@ -195,6 +286,160 @@ Describe 'Code Signing Functions' { } } + # The EnvVar and PfxFile sources load exactly one certificate, so validity is a gate applied + # to that certificate rather than part of selecting it. Every other test of those two sources + # in this file feeds the loader deliberately malformed input -- five arbitrary bytes, or an + # empty file named .pfx -- so the load throws and the gate below it never runs. Standing in + # for the load is what lets a certificate reach the gate at all, on any platform. See + # psake/PowerShellBuild#216. + Context 'Validation of a certificate loaded from EnvVar or PfxFile' { + + BeforeAll { + $script:codeSigningUsage = [PSCustomObject]@{ + ObjectId = '1.3.6.1.5.5.7.3.3' + FriendlyName = 'Code Signing' + } + $script:serverAuthenticationUsage = [PSCustomObject]@{ + ObjectId = '1.3.6.1.5.5.7.3.1' + FriendlyName = 'Server Authentication' + } + + $script:loadedValidCertificate = [PSCustomObject]@{ + Subject = 'CN=Loaded Valid Test Certificate' + Thumbprint = 'DDDD111122223333444455556666777788889999' + HasPrivateKey = $true + NotAfter = (Get-Date).AddDays(30) + EnhancedKeyUsageList = @($script:codeSigningUsage) + } + $script:loadedNoPrivateKeyCertificate = [PSCustomObject]@{ + Subject = 'CN=Loaded No Private Key Test Certificate' + Thumbprint = 'EEEE111122223333444455556666777788889999' + HasPrivateKey = $false + NotAfter = (Get-Date).AddDays(30) + EnhancedKeyUsageList = @($script:codeSigningUsage) + } + $script:loadedExpiredCertificate = [PSCustomObject]@{ + Subject = 'CN=Loaded Expired Test Certificate' + Thumbprint = 'FFFF111122223333444455556666777788889999' + HasPrivateKey = $true + NotAfter = (Get-Date).AddDays(-1) + EnhancedKeyUsageList = @($script:codeSigningUsage) + } + $script:loadedWrongUsageCertificate = [PSCustomObject]@{ + Subject = 'CN=Loaded Server Authentication Test Certificate' + Thumbprint = '1111222233334444555566667777888899990000' + HasPrivateKey = $true + NotAfter = (Get-Date).AddDays(30) + EnhancedKeyUsageList = @($script:serverAuthenticationUsage) + } + $script:loadedUnusableCertificate = [PSCustomObject]@{ + Subject = 'CN=Loaded Unusable Test Certificate' + Thumbprint = '2222333344445555666677778888999900001111' + HasPrivateKey = $false + NotAfter = (Get-Date).AddDays(-1) + EnhancedKeyUsageList = @($script:serverAuthenticationUsage) + } + + $script:pfxFilePath = Join-Path -Path $TestDrive -ChildPath 'codesign.pfx' + } + + BeforeEach { + # Any decodable payload will do. The load is stood in for, so nothing turns these bytes + # into a certificate, but Get-PSBuildCertificate rejects an empty or malformed variable + # before the load is reached. + $env:SIGNCERTIFICATE = [System.Convert]::ToBase64String([byte[]]@(1, 2, 3, 4, 5)) + } + + AfterEach { + Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue + Remove-Item env:\CERTIFICATEPASSWORD -ErrorAction SilentlyContinue + } + + It 'Throws when the loaded certificate has no private key' { + Mock -ModuleName PowerShellBuild -CommandName Import-PSBuildX509Certificate -MockWith { + $script:loadedNoPrivateKeyCertificate + } + + { Get-PSBuildCertificate -CertificateSource EnvVar } | + Should -Throw '*does not have an accessible private key*' + } + + It 'Throws when the loaded certificate has expired' { + Mock -ModuleName PowerShellBuild -CommandName Import-PSBuildX509Certificate -MockWith { + $script:loadedExpiredCertificate + } + + { Get-PSBuildCertificate -CertificateSource EnvVar } | + Should -Throw '*has expired*' + } + + It 'Throws when the loaded certificate has no Code Signing enhanced key usage' { + Mock -ModuleName PowerShellBuild -CommandName Import-PSBuildX509Certificate -MockWith { + $script:loadedWrongUsageCertificate + } + + { Get-PSBuildCertificate -CertificateSource EnvVar } | + Should -Throw '*Code Signing Enhanced Key Usage*' + } + + It 'Returns a certificate that passes every check' { + Mock -ModuleName PowerShellBuild -CommandName Import-PSBuildX509Certificate -MockWith { + $script:loadedValidCertificate + } + + $certificate = Get-PSBuildCertificate -CertificateSource EnvVar + + $certificate.Subject | Should -Be 'CN=Loaded Valid Test Certificate' + } + + It 'Returns an unusable certificate when SkipValidation is set' { + # SkipValidation drops the checks outright for these two sources, unlike Store and + # Thumbprint where an unexpired certificate is still preferred. + Mock -ModuleName PowerShellBuild -CommandName Import-PSBuildX509Certificate -MockWith { + $script:loadedUnusableCertificate + } + + $certificate = Get-PSBuildCertificate -CertificateSource EnvVar -SkipValidation + + $certificate.Subject | Should -Be 'CN=Loaded Unusable Test Certificate' + } + + It 'Applies the same validation to a certificate loaded from a PFX file' { + Mock -ModuleName PowerShellBuild -CommandName Import-PSBuildX509Certificate -MockWith { + $script:loadedExpiredCertificate + } + + { Get-PSBuildCertificate -CertificateSource PfxFile -PfxFilePath $script:pfxFilePath } | + Should -Throw '*has expired*' + } + + It 'Returns a certificate loaded from a PFX file that passes every check' { + Mock -ModuleName PowerShellBuild -CommandName Import-PSBuildX509Certificate -MockWith { + $script:loadedValidCertificate + } + + $certificate = Get-PSBuildCertificate -CertificateSource PfxFile -PfxFilePath $script:pfxFilePath + + $certificate.Subject | Should -Be 'CN=Loaded Valid Test Certificate' + } + + It 'Hands the decoded payload and the password from the environment to the loader' { + $env:SIGNCERTIFICATE = [System.Convert]::ToBase64String([byte[]]@(10, 20, 30)) + $env:CERTIFICATEPASSWORD = 'certificate-password' + Mock -ModuleName PowerShellBuild -CommandName Import-PSBuildX509Certificate -MockWith { + $script:loadedValidCertificate + } + + $null = Get-PSBuildCertificate -CertificateSource EnvVar + + Should -Invoke -ModuleName PowerShellBuild -CommandName Import-PSBuildX509Certificate ` + -Times 1 -Exactly -ParameterFilter { + $null -eq (Compare-Object -ReferenceObject $RawData -DifferenceObject ([byte[]]@(10, 20, 30))) -and + $Password -eq 'certificate-password' + } + } + } + # The Store and Thumbprint sources select one certificate out of many, so validity is part # of the selection rather than a gate applied to a single loaded certificate. SkipValidation # therefore relaxes the selection only as a fallback: a valid certificate is preferred @@ -213,27 +458,6 @@ Describe 'Code Signing Functions' { # psake/PowerShellBuild#197. Context 'SkipValidation for store-backed sources' { - BeforeAll { - $script:validCertificate = [PSCustomObject]@{ - Subject = 'CN=Valid Test Certificate' - Thumbprint = 'AAAA111122223333444455556666777788889999' - HasPrivateKey = $true - NotAfter = (Get-Date).AddDays(30) - } - $script:expiredCertificate = [PSCustomObject]@{ - Subject = 'CN=Expired Test Certificate' - Thumbprint = 'BBBB111122223333444455556666777788889999' - HasPrivateKey = $true - NotAfter = (Get-Date).AddDays(-1) - } - $script:noPrivateKeyCertificate = [PSCustomObject]@{ - Subject = 'CN=No Private Key Test Certificate' - Thumbprint = 'CCCC111122223333444455556666777788889999' - HasPrivateKey = $false - NotAfter = (Get-Date).AddDays(30) - } - } - Context 'Store source' { It 'Prefers a valid certificate over an expired one even when SkipValidation is set' -Skip:($null -ne $IsWindows -and -not $IsWindows) { # The expired certificate is returned first so that a naive implementation, one that From 38efe08766e3613eaeffa15592a48854fee28107 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:13:11 -0400 Subject: [PATCH 3/4] test: Run Invoke-PSBuildModuleSigning instead of asserting on Get-ChildItem Eleven tests passed and none of them called the function. The two named for file discovery called Get-ChildItem in the test body and counted the results, which asserts a fact about PowerShell rather than about this module, and one of them declared a Mock Set-AuthenticodeSignature it never used and could not have reached, because it too was missing -ModuleName. Both are replaced by tests that call the function with Set-AuthenticodeSignature mocked inside the module. They cover what nothing covered: that the search recurses, that the Include patterns filter, that the certificate, timestamp server, and hash algorithm reach the signing cmdlet rather than being accepted and dropped, that the defaults are the documented ones, and that a signature comes back for every file. One Windows-only test signs for real, against a self-signed certificate created for the run and removed from the store in AfterAll however the tests end. It passes an empty timestamp server so the suite stays off the network. Closes psake/PowerShellBuild#217 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011GYJrhbrDzqufaeMqD9QjT --- tests/Invoke-PSBuildModuleSigning.tests.ps1 | 199 +++++++++++++++++--- 1 file changed, 169 insertions(+), 30 deletions(-) diff --git a/tests/Invoke-PSBuildModuleSigning.tests.ps1 b/tests/Invoke-PSBuildModuleSigning.tests.ps1 index b85fffa..febd629 100644 --- a/tests/Invoke-PSBuildModuleSigning.tests.ps1 +++ b/tests/Invoke-PSBuildModuleSigning.tests.ps1 @@ -50,36 +50,6 @@ Describe 'Code Signing Functions' { Should -Throw } - It 'Searches for files matching Include patterns' -Skip:($null -ne $IsWindows -and -not $IsWindows) { - # Create test files - $testDir = Join-Path -Path $TestDrive -ChildPath 'SignTest' - New-Item -Path $testDir -ItemType Directory -Force | Out-Null - 'test' | Out-File -FilePath (Join-Path $testDir 'test.psd1') - 'test' | Out-File -FilePath (Join-Path $testDir 'test.psm1') - 'test' | Out-File -FilePath (Join-Path $testDir 'test.ps1') - 'test' | Out-File -FilePath (Join-Path $testDir 'test.txt') - - Mock Set-AuthenticodeSignature { - [PSCustomObject]@{ Status = 'Valid'; Path = $InputObject } - } - - # We need to skip this test if we can't create a real cert, or just verify file discovery - # Instead of mocking cert, just count the files that would be signed - $files = Get-ChildItem -Path $testDir -Recurse -Include '*.psd1', '*.psm1', '*.ps1' - $files.Count | Should -Be 3 # Should not include .txt file - } - - It 'Uses custom Include patterns when specified' -Skip:($null -ne $IsWindows -and -not $IsWindows) { - $testDir = Join-Path -Path $TestDrive -ChildPath 'SignTest2' - New-Item -Path $testDir -ItemType Directory -Force | Out-Null - 'test' | Out-File -FilePath (Join-Path $testDir 'test.psd1') - 'test' | Out-File -FilePath (Join-Path $testDir 'test.psm1') - - # Just verify file discovery with custom Include pattern - $files = Get-ChildItem -Path $testDir -Recurse -Include '*.psd1' - $files.Count | Should -Be 1 # Only .psd1 - } - It 'Accepts TimestampServer and HashAlgorithm parameters' { # Just verify parameters are accepted without error $command = Get-Command Invoke-PSBuildModuleSigning @@ -109,6 +79,175 @@ Describe 'Code Signing Functions' { $validValues | Should -Contain 'SHA512' $validValues | Should -Contain 'SHA1' } + + # Everything above this point reads the command's metadata; none of it runs the function, so + # its body reported no coverage at all and nothing checked that the Include patterns, the + # timestamp server, or the hash algorithm reach Set-AuthenticodeSignature rather than being + # accepted and dropped. See psake/PowerShellBuild#217. + # + # Set-AuthenticodeSignature exists only on Windows, so a mock of it cannot even be declared + # elsewhere, and signing is a Windows-only capability by design. Windows-only here means + # platform, not edition: $IsWindows does not exist on Windows PowerShell 5.1, where + # -Skip:(-not $IsWindows) would skip on the engine signing is most common on. See + # psake/PowerShellBuild#197. + Context 'Signing files' -Skip:($null -ne $IsWindows -and -not $IsWindows) { + + BeforeAll { + # A certificate that exists only in memory and is never added to a certificate store. It + # is here to satisfy the X509Certificate2 parameter type; nothing in this context signs + # anything, because Set-AuthenticodeSignature is mocked. + $script:signingKey = [System.Security.Cryptography.RSA]::Create(2048) + $certificateRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=PowerShellBuild Mocked Signing Test Certificate', + $script:signingKey, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + $script:mockedCertificate = $certificateRequest.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddDays(-1), + [DateTimeOffset]::UtcNow.AddDays(30) + ) + } + + AfterAll { + if ($script:mockedCertificate) { + $script:mockedCertificate.Dispose() + } + if ($script:signingKey) { + $script:signingKey.Dispose() + } + } + + BeforeEach { + # A fresh tree per test, so no test can be affected by what another one signed. The + # script under Public is there to prove the search recurses, and the text file to prove + # the Include patterns filter. + $script:moduleDirectory = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().ToString()) + $publicDirectory = Join-Path -Path $script:moduleDirectory -ChildPath 'Public' + New-Item -Path $publicDirectory -ItemType Directory -Force | Out-Null + "@{ ModuleVersion = '1.0.0' }" | Out-File -FilePath (Join-Path -Path $script:moduleDirectory -ChildPath 'TestModule.psd1') + '. $PSScriptRoot/Public/Get-Widget.ps1' | Out-File -FilePath (Join-Path -Path $script:moduleDirectory -ChildPath 'TestModule.psm1') + 'function Get-Widget { }' | Out-File -FilePath (Join-Path -Path $publicDirectory -ChildPath 'Get-Widget.ps1') + 'This file is documentation, not code.' | Out-File -FilePath (Join-Path -Path $script:moduleDirectory -ChildPath 'readme.txt') + + Mock -ModuleName PowerShellBuild -CommandName Set-AuthenticodeSignature -MockWith { + [PSCustomObject]@{ Status = 'Valid' } + } + } + + It 'Signs every file matching the default Include patterns' { + $null = Invoke-PSBuildModuleSigning -Path $script:moduleDirectory -Certificate $script:mockedCertificate + + Should -Invoke -ModuleName PowerShellBuild -CommandName Set-AuthenticodeSignature -Times 3 -Exactly + } + + It 'Signs a file nested below the path' { + $null = Invoke-PSBuildModuleSigning -Path $script:moduleDirectory -Certificate $script:mockedCertificate + + Should -Invoke -ModuleName PowerShellBuild -CommandName Set-AuthenticodeSignature -Times 1 -Exactly ` + -ParameterFilter { $LiteralPath -like '*Get-Widget.ps1' } + } + + It 'Does not sign a file that matches no Include pattern' { + $null = Invoke-PSBuildModuleSigning -Path $script:moduleDirectory -Certificate $script:mockedCertificate + + Should -Invoke -ModuleName PowerShellBuild -CommandName Set-AuthenticodeSignature -Times 0 -Exactly ` + -ParameterFilter { $LiteralPath -like '*readme.txt' } + } + + It 'Signs only the files matching a custom Include pattern' { + $null = Invoke-PSBuildModuleSigning -Path $script:moduleDirectory -Certificate $script:mockedCertificate ` + -Include '*.psd1' + + Should -Invoke -ModuleName PowerShellBuild -CommandName Set-AuthenticodeSignature -Times 1 -Exactly ` + -ParameterFilter { $LiteralPath -like '*TestModule.psd1' } + } + + It 'Passes the certificate, timestamp server, and hash algorithm it was given to the signing cmdlet' { + $null = Invoke-PSBuildModuleSigning -Path $script:moduleDirectory -Certificate $script:mockedCertificate ` + -TimestampServer 'http://timestamp.example.test' -HashAlgorithm 'SHA512' + + Should -Invoke -ModuleName PowerShellBuild -CommandName Set-AuthenticodeSignature -Times 3 -Exactly ` + -ParameterFilter { + $Certificate.Thumbprint -eq $script:mockedCertificate.Thumbprint -and + $TimestampServer -eq 'http://timestamp.example.test' -and + $HashAlgorithm -eq 'SHA512' + } + } + + It 'Passes the default timestamp server and hash algorithm when none are given' { + $null = Invoke-PSBuildModuleSigning -Path $script:moduleDirectory -Certificate $script:mockedCertificate + + Should -Invoke -ModuleName PowerShellBuild -CommandName Set-AuthenticodeSignature -Times 3 -Exactly ` + -ParameterFilter { + $TimestampServer -eq 'http://timestamp.digicert.com' -and + $HashAlgorithm -eq 'SHA256' + } + } + + It 'Returns what the signing cmdlet returned for every file' { + $signature = Invoke-PSBuildModuleSigning -Path $script:moduleDirectory -Certificate $script:mockedCertificate + + @($signature).Count | Should -Be 3 + } + } + + # The mocked context proves the function forwards what it was handed. Only a run against a + # certificate that can really sign proves the arguments it forwards are ones + # Set-AuthenticodeSignature will accept. + Context 'Signing with a real certificate' -Skip:($null -ne $IsWindows -and -not $IsWindows) { + + BeforeAll { + $script:certificateStoreLocation = 'Cert:\CurrentUser\My' + $script:realCertificate = New-SelfSignedCertificate -Type CodeSigningCert ` + -Subject 'CN=PowerShellBuild Integration Test Certificate' ` + -CertStoreLocation $script:certificateStoreLocation ` + -NotAfter (Get-Date).AddDays(1) + + $script:realModuleDirectory = Join-Path -Path $TestDrive -ChildPath 'RealSigning' + $publicDirectory = Join-Path -Path $script:realModuleDirectory -ChildPath 'Public' + New-Item -Path $publicDirectory -ItemType Directory -Force | Out-Null + "@{ ModuleVersion = '1.0.0' }" | Out-File -FilePath (Join-Path -Path $script:realModuleDirectory -ChildPath 'TestModule.psd1') + '. $PSScriptRoot/Public/Get-Widget.ps1' | Out-File -FilePath (Join-Path -Path $script:realModuleDirectory -ChildPath 'TestModule.psm1') + 'function Get-Widget { }' | Out-File -FilePath (Join-Path -Path $publicDirectory -ChildPath 'Get-Widget.ps1') + 'This file is documentation, not code.' | Out-File -FilePath (Join-Path -Path $script:realModuleDirectory -ChildPath 'readme.txt') + + # An empty timestamp server keeps the test off the network. A timestamp is what keeps a + # signature valid past the certificate's expiry, and nothing here outlives the test run. + $script:realSignature = Invoke-PSBuildModuleSigning -Path $script:realModuleDirectory ` + -Certificate $script:realCertificate -TimestampServer '' + } + + AfterAll { + # This runs on a real person's machine. Whatever happened above, the certificate this + # context installed does not outlive it. + if ($script:realCertificate) { + Get-ChildItem -Path $script:certificateStoreLocation | + Where-Object { $_.Thumbprint -eq $script:realCertificate.Thumbprint } | + Remove-Item -Force + } + } + + It 'Signs exactly the files matching the Include patterns, including nested ones' { + $signedFileName = @($script:realSignature | ForEach-Object { Split-Path -Path $_.Path -Leaf } | Sort-Object) + + $signedFileName | Should -Be @('Get-Widget.ps1', 'TestModule.psd1', 'TestModule.psm1') + } + + It 'Signs every file with the certificate it was given' { + $signerThumbprint = @($script:realSignature.SignerCertificate.Thumbprint | Sort-Object -Unique) + + $signerThumbprint | Should -Be @($script:realCertificate.Thumbprint) + } + + It 'Leaves a signature the platform reads back from the file' { + $signature = Get-AuthenticodeSignature -FilePath ( + Join-Path -Path $script:realModuleDirectory -ChildPath 'TestModule.psm1' + ) + + $signature.SignerCertificate.Thumbprint | Should -Be $script:realCertificate.Thumbprint + } + } } } From 8e5587370c1c7344e8b0286a2789f0c2ef24e75f Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Thu, 3 Sep 2026 13:57:34 -0400 Subject: [PATCH 4/4] fix: Drop the password guard that rested on a distinction that does not exist The guard omitted the Password parameter when the password environment variable was unset, on the stated premise that no password and an empty one are not the same thing to the PFX loader. The premise is false, and the guard could not have acted on it anyway: an unbound [string] parameter is an empty string, not null, so the function had no way to express "no password" in the first place. Measured on Windows PowerShell 5.1 and PowerShell 7, loading a password-less PFX, a password-protected one, and one exported with an explicitly empty password, each with a genuine null (NullString::Value), with an empty string, and through the single-argument overload. Null and empty behave identically in every cell: both load the two loadable payloads and both fail the protected one with the same message. Only the single-argument overload differs, and only in the text of the failure it produces on a protected PFX. Three tests cover the load end to end with the real constructor rather than a stand-in: a password-less PFX with the password variable unset, a protected one with the password supplied, and a protected one with the wrong password, which is what proves the password is used at all. They pass -SkipValidation because EnhancedKeyUsageList is edition-dependent; the validation itself is covered by the fake certificates. Refs psake/PowerShellBuild#216 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MxiVZmXp31BREi3aNDop5y --- .../Private/Import-PSBuildX509Certificate.ps1 | 7 +- .../Public/Get-PSBuildCertificate.ps1 | 10 +-- tests/Get-PSBuildCertificate.tests.ps1 | 77 +++++++++++++++++++ 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/PowerShellBuild/Private/Import-PSBuildX509Certificate.ps1 b/PowerShellBuild/Private/Import-PSBuildX509Certificate.ps1 index 7bdb3eb..b1d4264 100644 --- a/PowerShellBuild/Private/Import-PSBuildX509Certificate.ps1 +++ b/PowerShellBuild/Private/Import-PSBuildX509Certificate.ps1 @@ -20,8 +20,11 @@ function Import-PSBuildX509Certificate { .PARAMETER RawData The decoded PFX bytes to construct the certificate from. .PARAMETER Password - Password protecting the PFX bytes. Omit it when the payload carries no password; an - empty string and no password at all are not the same thing to the loader. + Password protecting the PFX bytes. An empty string means no password: the loader treats + a null and an empty password identically, measured on Windows PowerShell 5.1 and + PowerShell 7 against a password-less PFX, a password-protected one, and one exported with + an explicitly empty password. Nothing here needs to tell the two apart, which is just as + well, because a PowerShell [string] parameter cannot carry a null to begin with. .PARAMETER FilePath Path to the PFX or P12 file to construct the certificate from. .PARAMETER FilePassword diff --git a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 index 9a6cc62..f12fae2 100644 --- a/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 +++ b/PowerShellBuild/Public/Get-PSBuildCertificate.ps1 @@ -215,15 +215,7 @@ throw "Environment variable '$CertificateEnvVar' does not contain a valid Base64-encoded PFX value." } $password = [System.Environment]::GetEnvironmentVariable($CertificatePasswordEnvVar) - - # GetEnvironmentVariable returns $null when the variable is not set, and to the PFX - # loader no password at all is not the same thing as an empty one, so the parameter - # is supplied only when the variable held something. - $certificateParameter = @{ RawData = $buffer } - if ($null -ne $password) { - $certificateParameter['Password'] = $password - } - $cert = Import-PSBuildX509Certificate @certificateParameter + $cert = Import-PSBuildX509Certificate -RawData $buffer -Password $password Write-Verbose ($LocalizedData.CertificateResolvedFromEnvVar -f $CertificateEnvVar) } 'PfxFile' { diff --git a/tests/Get-PSBuildCertificate.tests.ps1 b/tests/Get-PSBuildCertificate.tests.ps1 index 3bcad98..0a6752a 100644 --- a/tests/Get-PSBuildCertificate.tests.ps1 +++ b/tests/Get-PSBuildCertificate.tests.ps1 @@ -188,6 +188,83 @@ Describe 'Code Signing Functions' { } Context 'EnvVar mode' { + + BeforeAll { + # Two payloads from one generated certificate: one PFX with no password at all, one + # protected by a password. Everything else in this file stands in for the load; these two + # tests run the real X509Certificate2 constructor, which is the only place a regression in + # how the password reaches it could show up. + $script:envVarSigningKey = [System.Security.Cryptography.RSA]::Create(2048) + $envVarCertificateRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=PowerShellBuild EnvVar Test Certificate', + $script:envVarSigningKey, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + $script:envVarCertificate = $envVarCertificateRequest.CreateSelfSigned( + [DateTimeOffset]::UtcNow.AddDays(-1), + [DateTimeOffset]::UtcNow.AddDays(30) + ) + $pfxContentType = [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx + $script:passwordlessPfx = $script:envVarCertificate.Export($pfxContentType) + $script:protectedPfx = $script:envVarCertificate.Export($pfxContentType, 'test-certificate-password') + } + + AfterAll { + if ($script:envVarCertificate) { + $script:envVarCertificate.Dispose() + } + if ($script:envVarSigningKey) { + $script:envVarSigningKey.Dispose() + } + } + + AfterEach { + Remove-Item env:\SIGNCERTIFICATE -ErrorAction SilentlyContinue + Remove-Item env:\CERTIFICATEPASSWORD -ErrorAction SilentlyContinue + } + + # SkipValidation is what keeps these two tests cross-platform. The validation they would + # otherwise run ends at the Code Signing extended key usage, and EnhancedKeyUsageList comes + # from PowerShell's own type data: this very certificate reports the usage on Windows + # PowerShell 5.1 and an empty list on PowerShell 7. What is under test here is the load, + # not the validation, which the fake certificates cover. + It 'Loads a certificate from a PFX that carries no password' { + # The password variable is unset, which is what the overwhelming majority of consumers + # have. GetEnvironmentVariable returns $null for it, a [string] parameter turns that into + # an empty string, and the loader treats the two identically -- measured on both editions + # against a password-less PFX, a protected one, and one exported with an empty password. + $env:SIGNCERTIFICATE = [System.Convert]::ToBase64String($script:passwordlessPfx) + + $certificate = Get-PSBuildCertificate -CertificateSource EnvVar -SkipValidation + + try { + $certificate.Subject | Should -Be 'CN=PowerShellBuild EnvVar Test Certificate' + } finally { + if ($certificate) { $certificate.Dispose() } + } + } + + It 'Loads a password-protected PFX with the password from the environment' { + $env:SIGNCERTIFICATE = [System.Convert]::ToBase64String($script:protectedPfx) + $env:CERTIFICATEPASSWORD = 'test-certificate-password' + + $certificate = Get-PSBuildCertificate -CertificateSource EnvVar -SkipValidation + + try { + $certificate.Subject | Should -Be 'CN=PowerShellBuild EnvVar Test Certificate' + } finally { + if ($certificate) { $certificate.Dispose() } + } + } + + It 'Throws when the password from the environment is the wrong one' { + $env:SIGNCERTIFICATE = [System.Convert]::ToBase64String($script:protectedPfx) + $env:CERTIFICATEPASSWORD = 'not-the-certificate-password' + + { Get-PSBuildCertificate -CertificateSource EnvVar -SkipValidation } | Should -Throw + } + It 'Throws when the environment variable holds nothing' { # The BeforeEach above clears SIGNCERTIFICATE, which is the situation a consumer lands in # when the CI secret was never wired up.