From aae62c4ee10d445fdfd0417c2b61c96b2f54b486 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:12:50 -0400 Subject: [PATCH 1/3] 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 3195c5bd710cb788278513c3bc5b7bafd55f7b60 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:13:01 -0400 Subject: [PATCH 2/3] 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 1a597eca23918ad869d6350816331c1380aef5ff Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Fri, 28 Aug 2026 18:13:11 -0400 Subject: [PATCH 3/3] 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 + } + } } }