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) } } 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 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 + } + } } }