From 35eb614779d4c2ec6a24bf54ecf654e70480c0bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Mon, 25 May 2026 21:44:11 -0400 Subject: [PATCH] Improve PowerShell module signing validation Add pipeline-friendly module validation and signing workflows, remove legacy portable aliases, and align portable TrustedPublisher handling with Windows PowerShell execution policy behavior. This adds TrustedPublisher and Disallowed handling in the file-backed pcert store, expands Jordan Borean parity coverage, and fixes portable trust-chain edge cases for explicit anchors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Devolutions.Psign/Devolutions.Psign.psd1 | 5 +- .../Devolutions.Psign/Devolutions.Psign.psm1 | 14 +- .../PortableCertStore.Provider.Tests.ps1 | 8 + .../PortableSignature.Compatibility.Tests.ps1 | 15 +- .../ProtectPsignModule.Expanded.Tests.ps1 | 58 ++- .../tests/TestPsignModule.Expanded.Tests.ps1 | 391 +++++++++++++++++- crates/psign-authenticode-trust/src/chain.rs | 30 +- .../src/trust_pkcs7.rs | 6 + docs/portable-powershell-module.md | 13 + .../Cmdlets/GetPsignSignatureCommand.cs | 1 - .../Cmdlets/ProtectPsignModuleCommand.cs | 147 +++++-- .../Cmdlets/SetPsignSignatureCommand.cs | 73 ++-- .../Cmdlets/TestPsignModuleCommand.cs | 229 ++++++++-- .../Models/PsignModuleValidation.cs | 5 +- .../Provider/CertStorePathHelper.cs | 3 +- 15 files changed, 842 insertions(+), 156 deletions(-) diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 index 02d5e42..b16b3ef 100644 --- a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 @@ -18,10 +18,7 @@ 'Unprotect-PsignSignature' ) FunctionsToExport = @() - AliasesToExport = @( - 'Get-PortableSignature', - 'Set-PortableSignature' - ) + AliasesToExport = @() PrivateData = @{ PSData = @{ Tags = @('Authenticode', 'CodeSigning', 'Portable', 'psign') diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 index 718d263..9fc4379 100644 --- a/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psm1 @@ -2,7 +2,7 @@ # Argument completers for common parameters -Register-ArgumentCompleter -CommandName Get-PsignSignature, Get-PortableSignature, Set-PsignSignature, Set-PortableSignature, Protect-PsignModule -ParameterName Thumbprint -ScriptBlock { +Register-ArgumentCompleter -CommandName Get-PsignSignature, Set-PsignSignature, Protect-PsignModule -ParameterName Thumbprint -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $baseDir = if ($fakeBoundParameters.ContainsKey('CertStoreDirectory')) { $fakeBoundParameters['CertStoreDirectory'] @@ -28,35 +28,35 @@ Register-ArgumentCompleter -CommandName Get-PsignSignature, Get-PortableSignatur } } -Register-ArgumentCompleter -CommandName Set-PsignSignature, Set-PortableSignature, Protect-PsignModule -ParameterName StoreName -ScriptBlock { +Register-ArgumentCompleter -CommandName Set-PsignSignature, Protect-PsignModule -ParameterName StoreName -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) - @('MY', 'Root', 'CA', 'Trust', 'Disallowed') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + @('MY', 'Root', 'CA', 'Trust', 'TrustedPublisher', 'Disallowed') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } } -Register-ArgumentCompleter -CommandName Set-PsignSignature, Set-PortableSignature, Protect-PsignModule -ParameterName HashAlgorithm -ScriptBlock { +Register-ArgumentCompleter -CommandName Set-PsignSignature, Protect-PsignModule -ParameterName HashAlgorithm -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) @('Sha256', 'Sha384', 'Sha512') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } } -Register-ArgumentCompleter -CommandName Set-PsignSignature, Set-PortableSignature, Protect-PsignModule -ParameterName IncludeChain -ScriptBlock { +Register-ArgumentCompleter -CommandName Set-PsignSignature, Protect-PsignModule -ParameterName IncludeChain -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) @('Signer', 'NotRoot', 'All') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } } -Register-ArgumentCompleter -CommandName Set-PsignSignature, Set-PortableSignature, Protect-PsignModule -ParameterName TimestampHashAlgorithm -ScriptBlock { +Register-ArgumentCompleter -CommandName Set-PsignSignature, Protect-PsignModule -ParameterName TimestampHashAlgorithm -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) @('Sha1', 'Sha256', 'Sha384', 'Sha512') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } } -Register-ArgumentCompleter -CommandName Get-PsignSignature, Get-PortableSignature -ParameterName RevocationMode -ScriptBlock { +Register-ArgumentCompleter -CommandName Get-PsignSignature -ParameterName RevocationMode -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) @('Off', 'BestEffort', 'Require') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) diff --git a/PowerShell/tests/PortableCertStore.Provider.Tests.ps1 b/PowerShell/tests/PortableCertStore.Provider.Tests.ps1 index fc75596..9f4ff64 100644 --- a/PowerShell/tests/PortableCertStore.Provider.Tests.ps1 +++ b/PowerShell/tests/PortableCertStore.Provider.Tests.ps1 @@ -64,6 +64,7 @@ Describe 'pcert:\ Provider - Navigation' { $names | Should -Contain 'MY' $names | Should -Contain 'Root' $names | Should -Contain 'CA' + $names | Should -Contain 'TrustedPublisher' } It 'can cd into scope and store' { @@ -119,6 +120,13 @@ Describe 'pcert:\ Provider - Certificate CRUD' { $copied.Thumbprint | Should -Be $script:TestCert.Thumbprint } + It 'can use TrustedPublisher as a well-known store' { + Copy-Item "pcert:\CurrentUser\MY\$($script:TestCert.Thumbprint)" pcert:\CurrentUser\TrustedPublisher + Test-Path "pcert:\CurrentUser\TrustedPublisher\$($script:TestCert.Thumbprint)" | Should -BeTrue + $copied = Get-Item "pcert:\CurrentUser\TrustedPublisher\$($script:TestCert.Thumbprint)" + $copied.Thumbprint | Should -Be $script:TestCert.Thumbprint + } + It 'can remove a certificate' { # Remove from Root (the copy) Remove-Item "pcert:\CurrentUser\Root\$($script:TestCert.Thumbprint)" diff --git a/PowerShell/tests/PortableSignature.Compatibility.Tests.ps1 b/PowerShell/tests/PortableSignature.Compatibility.Tests.ps1 index e2b001e..44faf77 100644 --- a/PowerShell/tests/PortableSignature.Compatibility.Tests.ps1 +++ b/PowerShell/tests/PortableSignature.Compatibility.Tests.ps1 @@ -1,11 +1,10 @@ Set-StrictMode -Version Latest function script:Ensure-PortableSignatureModule { - if (-not (Get-Command Get-PsignSignature -ErrorAction SilentlyContinue)) { - $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - $modulePath = Join-Path (Join-Path $repoRoot 'PowerShell\Devolutions.Psign') 'Devolutions.Psign.psd1' - Import-Module $modulePath -Force - } + Remove-Module Devolutions.Psign -Force -ErrorAction SilentlyContinue + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $modulePath = Join-Path (Join-Path $repoRoot 'PowerShell\Devolutions.Psign') 'Devolutions.Psign.psd1' + Import-Module $modulePath -Force } Describe 'Portable PowerShell Authenticode compatibility' { @@ -39,9 +38,9 @@ Describe 'Portable PowerShell Authenticode compatibility' { } } - It 'preserves backward-compatible aliases Get-PortableSignature and Set-PortableSignature' { - Get-Command Get-PortableSignature -ErrorAction Stop | Should -Not -BeNullOrEmpty - Get-Command Set-PortableSignature -ErrorAction Stop | Should -Not -BeNullOrEmpty + It 'does not export legacy PortableSignature aliases' { + Get-Command Get-PortableSignature -ErrorAction SilentlyContinue | Should -BeNullOrEmpty + Get-Command Set-PortableSignature -ErrorAction SilentlyContinue | Should -BeNullOrEmpty } It 'exposes built-in enum types on compatibility properties' { diff --git a/PowerShell/tests/ProtectPsignModule.Expanded.Tests.ps1 b/PowerShell/tests/ProtectPsignModule.Expanded.Tests.ps1 index fe5b97e..811a6f4 100644 --- a/PowerShell/tests/ProtectPsignModule.Expanded.Tests.ps1 +++ b/PowerShell/tests/ProtectPsignModule.Expanded.Tests.ps1 @@ -42,8 +42,14 @@ BeforeAll { [System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12, $script:PfxPassword)) function script:New-TestModule { - param([string]$Name, [switch]$WithManifest, [int]$ExtraFiles = 0) - $modDir = Join-Path $script:TestDir $Name + param( + [string]$Name, + [switch]$WithManifest, + [int]$ExtraFiles = 0, + [string]$ModuleVersion = '1.0.0', + [string]$Directory + ) + $modDir = if ($Directory) { $Directory } else { Join-Path $script:TestDir $Name } New-Item -ItemType Directory -Path $modDir -Force | Out-Null Set-Content -LiteralPath (Join-Path $modDir "$Name.psm1") -Value "function Get-$Name { '$Name' }" -Encoding UTF8 @@ -51,7 +57,7 @@ BeforeAll { if ($WithManifest) { $psdContent = @" @{ - ModuleVersion = '1.0.0' + ModuleVersion = '$ModuleVersion' RootModule = '$Name.psm1' FunctionsToExport = @('Get-$Name') } @@ -131,6 +137,52 @@ Describe 'Protect-PsignModule -IncludeUnreferenced' { } } +Describe 'Protect-PsignModule pipeline input' { + It 'accepts Get-Module output and signs the piped module version' { + $moduleName = 'PipeProtect' + $modulePathRoot = Join-Path $script:TestDir 'psmodulepath-protect' + $moduleVersionRoot = Join-Path $modulePathRoot $moduleName + $oldModuleDir = Join-Path $moduleVersionRoot '1.0.0' + $newModuleDir = Join-Path $moduleVersionRoot '2.0.0' + $null = New-TestModule -Name $moduleName -WithManifest -Directory $oldModuleDir + $null = New-TestModule -Name $moduleName -WithManifest -ModuleVersion '2.0.0' -Directory $newModuleDir + + $originalPSModulePath = $env:PSModulePath + try { + $env:PSModulePath = "$modulePathRoot$([System.IO.Path]::PathSeparator)$originalPSModulePath" + + $module = Get-Module -ListAvailable -Name $moduleName | + Where-Object Version -eq ([version]'1.0.0') + $result = $module | Protect-PsignModule -CertificatePath $script:CertPath -PrivateKeyPath $script:KeyPath + $result.ModuleName | Should -Be $moduleName + $result.ModulePath | Should -Be $oldModuleDir + $result.Succeeded | Should -Be $result.TotalFiles + + (Test-PsignModule -Path $oldModuleDir -Policy AllSigned -SkipTrust).Valid | Should -BeTrue + (Test-PsignModule -Path $newModuleDir -Policy AllSigned -SkipTrust).Valid | Should -BeFalse + } finally { + $env:PSModulePath = $originalPSModulePath + } + } + + It 'accepts Get-InstalledModule-style output with InstalledLocation' { + $moduleName = 'InstalledProtect' + $modDir = New-TestModule -Name $moduleName -WithManifest -ModuleVersion '2.0.0' + $installedModule = [pscustomobject]@{ + Name = $moduleName + Version = '2.0.0' + InstalledLocation = $modDir + Repository = 'TestRepository' + } + + $result = $installedModule | Protect-PsignModule -CertificatePath $script:CertPath -PrivateKeyPath $script:KeyPath + $result.ModuleName | Should -Be $moduleName + $result.ModulePath | Should -Be $modDir + $result.Succeeded | Should -Be $result.TotalFiles + (Test-PsignModule -Path $modDir -Policy AllSigned -SkipTrust).Valid | Should -BeTrue + } +} + Describe 'Protect-PsignModule result properties' { It 'returns PsignModuleSigningResult with per-file details' { $modDir = New-TestModule -Name 'ResultProps' -WithManifest -ExtraFiles 1 diff --git a/PowerShell/tests/TestPsignModule.Expanded.Tests.ps1 b/PowerShell/tests/TestPsignModule.Expanded.Tests.ps1 index 3a39653..b77b37f 100644 --- a/PowerShell/tests/TestPsignModule.Expanded.Tests.ps1 +++ b/PowerShell/tests/TestPsignModule.Expanded.Tests.ps1 @@ -4,40 +4,171 @@ $env:PSIGN_NO_AUTO_TRUST = '1' BeforeAll { $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $script:TestDir = Join-Path ([System.IO.Path]::GetTempPath()) "psign-testmod-$([System.Guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -ItemType Directory -Path $script:TestDir -Force | Out-Null + $env:PSIGN_CERT_STORE = Join-Path $script:TestDir 'cert-store' + $modulePath = Join-Path $repoRoot 'PowerShell\Devolutions.Psign\Devolutions.Psign.psd1' + Remove-Module Devolutions.Psign -Force -ErrorAction SilentlyContinue Import-Module $modulePath -Force - $script:TestDir = Join-Path ([System.IO.Path]::GetTempPath()) "psign-testmod-$([System.Guid]::NewGuid().ToString('N').Substring(0,8))" - New-Item -ItemType Directory -Path $script:TestDir -Force | Out-Null + function script:New-TestSerialNumber { + $serial = [byte[]]::new(16) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($serial) + $serial[0] = $serial[0] -band 0x7F + $serial + } + + function script:New-TestCaRequest { + param( + [string]$Subject, + [System.Security.Cryptography.RSA]$Key + ) + + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $Subject, + $Key, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($true, $false, 0, $true)) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyCertSign, + $true)) + $ekuOids = [System.Security.Cryptography.OidCollection]::new() + $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3')) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false)) + $request + } + + function script:New-TestSignerRequest { + param( + [string]$Subject, + [System.Security.Cryptography.RSA]$Key + ) + + $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $Subject, + $Key, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true)) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, $true)) + $ekuOids = [System.Security.Cryptography.OidCollection]::new() + $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3')) + $request.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false)) + $request + } - # Create a signing cert + function script:Export-TestCertificate { + param( + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [string]$CertificatePath, + [string]$PrivateKeyPath + ) + + [System.IO.File]::WriteAllBytes($CertificatePath, $Certificate.Export( + [System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) + + if ($PrivateKeyPath) { + $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) + [System.IO.File]::WriteAllText( + $PrivateKeyPath, + [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey())) + } + } + + # Create a self-signed signing cert $rsa = [System.Security.Cryptography.RSA]::Create(2048) - $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( - 'CN=psign testmod test', - $rsa, + $request = New-TestSignerRequest -Subject 'CN=psign testmod test' -Key $rsa + $script:Cert = $request.CreateSelfSigned( + [System.DateTimeOffset]::UtcNow.AddDays(-1), + [System.DateTimeOffset]::UtcNow.AddDays(30)) + + $otherRsa = [System.Security.Cryptography.RSA]::Create(2048) + $otherRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + 'CN=psign unrelated trust anchor', + $otherRsa, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) - $request.CertificateExtensions.Add( - [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( - [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, $true)) - $ekuOids = [System.Security.Cryptography.OidCollection]::new() - $null = $ekuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.3')) - $request.CertificateExtensions.Add( - [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($ekuOids, $false)) - $script:Cert = $request.CreateSelfSigned( + $script:OtherCert = $otherRequest.CreateSelfSigned( [System.DateTimeOffset]::UtcNow.AddDays(-1), [System.DateTimeOffset]::UtcNow.AddDays(30)) $script:CertPath = Join-Path $script:TestDir 'signer.cer' $script:KeyPath = Join-Path $script:TestDir 'signer.key' - [System.IO.File]::WriteAllBytes($script:CertPath, $script:Cert.Export( - [System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)) - [System.IO.File]::WriteAllText($script:KeyPath, - [System.Security.Cryptography.PemEncoding]::WriteString('PRIVATE KEY', $rsa.ExportPkcs8PrivateKey())) + Export-TestCertificate -Certificate $script:Cert -CertificatePath $script:CertPath -PrivateKeyPath $script:KeyPath + + # Create a root -> intermediate -> leaf signing chain matching Jordan's scenarios. + $chainNotBefore = [System.DateTimeOffset]::UtcNow.AddDays(-1) + $chainRootNotAfter = [System.DateTimeOffset]::UtcNow.AddDays(31) + $chainLeafNotAfter = [System.DateTimeOffset]::UtcNow.AddDays(30) + + $rootRsa = [System.Security.Cryptography.RSA]::Create(2048) + $rootRequest = New-TestCaRequest -Subject 'CN=psign jordan root' -Key $rootRsa + $rootCert = $rootRequest.CreateSelfSigned( + $chainNotBefore, + $chainRootNotAfter) + + $intermediateRsa = [System.Security.Cryptography.RSA]::Create(2048) + $intermediateRequest = New-TestCaRequest -Subject 'CN=psign jordan intermediate' -Key $intermediateRsa + $intermediatePublic = $intermediateRequest.Create( + $rootCert, + $chainNotBefore, + $chainLeafNotAfter, + (New-TestSerialNumber)) + $intermediateCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey( + $intermediatePublic, + $intermediateRsa) + + $leafRsa = [System.Security.Cryptography.RSA]::Create(2048) + $leafRequest = New-TestSignerRequest -Subject 'CN=psign jordan signer' -Key $leafRsa + $leafPublic = $leafRequest.Create( + $intermediateCert, + $chainNotBefore, + $chainLeafNotAfter, + (New-TestSerialNumber)) + $leafCert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey( + $leafPublic, + $leafRsa) + + $chainRootPath = Join-Path $script:TestDir 'chain-root.cer' + $chainIntermediatePath = Join-Path $script:TestDir 'chain-intermediate.cer' + $chainLeafPath = Join-Path $script:TestDir 'chain-leaf.cer' + $chainLeafKeyPath = Join-Path $script:TestDir 'chain-leaf.key' + Export-TestCertificate -Certificate $rootCert -CertificatePath $chainRootPath + Export-TestCertificate -Certificate $intermediateCert -CertificatePath $chainIntermediatePath + Export-TestCertificate -Certificate $leafCert -CertificatePath $chainLeafPath -PrivateKeyPath $chainLeafKeyPath + + $script:Chain = [pscustomobject]@{ + Root = $rootCert + Intermediate = $intermediateCert + Leaf = $leafCert + RootPath = $chainRootPath + IntermediatePath = $chainIntermediatePath + LeafPath = $chainLeafPath + LeafKeyPath = $chainLeafKeyPath + } function script:New-TestModule { - param([string]$Name, [switch]$WithManifest, [switch]$Sign, [int]$ExtraFiles = 0) - $modDir = Join-Path $script:TestDir $Name + param( + [string]$Name, + [switch]$WithManifest, + [switch]$Sign, + [int]$ExtraFiles = 0, + [string]$ModuleVersion = '1.0.0', + [string]$Directory, + [string]$CertificatePath = $script:CertPath, + [string]$PrivateKeyPath = $script:KeyPath, + [string[]]$ChainCertificatePath = @() + ) + $modDir = if ($Directory) { $Directory } else { Join-Path $script:TestDir $Name } New-Item -ItemType Directory -Path $modDir -Force | Out-Null # Root module @@ -46,7 +177,7 @@ BeforeAll { if ($WithManifest) { $psdContent = @" @{ - ModuleVersion = '1.0.0' + ModuleVersion = '$ModuleVersion' RootModule = '$Name.psm1' FunctionsToExport = @('Get-$Name') } @@ -60,17 +191,68 @@ BeforeAll { if ($Sign) { Get-ChildItem -LiteralPath $modDir -Filter '*.ps*' -File | ForEach-Object { - $null = Set-PsignSignature -LiteralPath $_.FullName -CertificatePath $script:CertPath -PrivateKeyPath $script:KeyPath + $signParams = @{ + LiteralPath = $_.FullName + CertificatePath = $CertificatePath + PrivateKeyPath = $PrivateKeyPath + } + if ($ChainCertificatePath.Count -gt 0) { + $signParams.ChainCertificatePath = $ChainCertificatePath + } + $null = Set-PsignSignature @signParams } } $modDir } + + function script:Clear-TestPublisherStores { + $thumbprints = @( + $script:Cert.Thumbprint + $script:Chain.Root.Thumbprint + $script:Chain.Intermediate.Thumbprint + $script:Chain.Leaf.Thumbprint + ) + + foreach ($store in @('Trust', 'TrustedPublisher', 'Disallowed')) { + foreach ($scope in @('CurrentUser', 'LocalMachine')) { + foreach ($thumbprint in $thumbprints) { + $path = "pcert:\$scope\$store\$thumbprint" + Remove-Item -LiteralPath $path -ErrorAction SilentlyContinue + } + } + } + } + + function script:Add-TestCertificateToStore { + param( + [ValidateSet('CurrentUser', 'LocalMachine')] + [string]$Scope = 'CurrentUser', + [ValidateSet('Trust', 'TrustedPublisher', 'Disallowed')] + [string]$Store, + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + New-Item -Path "pcert:\$Scope\$Store\$($Certificate.Thumbprint)" -Value $Certificate | Out-Null + } + + function script:New-ChainSignedTestModule { + param([string]$Name) + + New-TestModule ` + -Name $Name ` + -WithManifest ` + -Sign ` + -CertificatePath $script:Chain.LeafPath ` + -PrivateKeyPath $script:Chain.LeafKeyPath ` + -ChainCertificatePath @($script:Chain.IntermediatePath) + } } AfterAll { if (Test-Path $script:TestDir) { Remove-Item -Recurse -Force $script:TestDir } + Remove-Item Env:\PSIGN_CERT_STORE -ErrorAction SilentlyContinue } Describe 'Test-PsignModule -Policy AllSigned' { @@ -124,6 +306,171 @@ Describe 'Test-PsignModule error handling' { } } +Describe 'Test-PsignModule pipeline input' { + It 'accepts Get-Module output and validates the piped module version' { + $moduleName = 'NamedModule' + $modulePathRoot = Join-Path $script:TestDir 'psmodulepath' + $moduleVersionRoot = Join-Path $modulePathRoot $moduleName + $oldModuleDir = Join-Path $moduleVersionRoot '1.0.0' + $newModuleDir = Join-Path $moduleVersionRoot '2.0.0' + $null = New-TestModule -Name $moduleName -WithManifest -Directory $oldModuleDir + $null = New-TestModule -Name $moduleName -WithManifest -Sign -ModuleVersion '2.0.0' -Directory $newModuleDir + + $originalPSModulePath = $env:PSModulePath + try { + $env:PSModulePath = "$modulePathRoot$([System.IO.Path]::PathSeparator)$originalPSModulePath" + + $modules = Get-Module -ListAvailable -Name $moduleName | Sort-Object Version + $results = $modules | Test-PsignModule -Policy AllSigned -SkipTrust + $results | Should -HaveCount 2 + + $oldResult = $results | Where-Object ModulePath -eq $oldModuleDir + $oldResult.Valid | Should -BeFalse + $oldResult.ModuleName | Should -Be $moduleName + + $newResult = $results | Where-Object ModulePath -eq $newModuleDir + $newResult.Valid | Should -BeTrue + $newResult.ModuleName | Should -Be $moduleName + } finally { + $env:PSModulePath = $originalPSModulePath + } + } + + It 'accepts Get-InstalledModule-style output with InstalledLocation' { + $moduleName = 'InstalledModule' + $modDir = New-TestModule -Name $moduleName -WithManifest -Sign -ModuleVersion '2.0.0' + $installedModule = [pscustomobject]@{ + Name = $moduleName + Version = '2.0.0' + InstalledLocation = $modDir + Repository = 'TestRepository' + } + + $result = $installedModule | Test-PsignModule -Policy AllSigned -SkipTrust + $result.Valid | Should -BeTrue + $result.ModuleName | Should -Be $moduleName + $result.ModulePath | Should -Be $modDir + } +} + +Describe 'Test-PsignModule trusted publisher parity' { + BeforeEach { + Clear-TestPublisherStores + } + + It 'requires the self-signed leaf in TrustedPublisher, not Trust' { + $modDir = New-TestModule -Name 'PublisherTrust' -WithManifest -Sign + + Add-TestCertificateToStore -Store Trust -Certificate $script:Cert + $trustOnly = Test-PsignModule -Path $modDir -Policy AllSigned -SkipTrust -RequireTrustedPublisher + $trustOnly.Valid | Should -BeFalse + ($trustOnly.Files | Where-Object RequiredByPolicy | Select-Object -First 1).IsTrustedPublisher | Should -BeFalse + + Add-TestCertificateToStore -Store TrustedPublisher -Certificate $script:Cert + $trustedPublisher = Test-PsignModule -Path $modDir -Policy AllSigned -SkipTrust -RequireTrustedPublisher + $trustedPublisher.Valid | Should -BeTrue + ($trustedPublisher.Files | Where-Object RequiredByPolicy | Select-Object -First 1).IsTrustedPublisher | Should -BeTrue + } + + It 'does not use self-signed TrustedPublisher membership to bypass chain trust' { + $modDir = New-TestModule -Name 'PublisherNeedsRoot' -WithManifest -Sign + Add-TestCertificateToStore -Store TrustedPublisher -Certificate $script:Cert + + $result = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:OtherCert -RequireTrustedPublisher + $result.Valid | Should -BeFalse + ($result.Files | Where-Object RequiredByPolicy | Select-Object -First 1).IsTrustedPublisher | Should -BeTrue + ($result.Files | Where-Object RequiredByPolicy | Select-Object -First 1).FailureReason | Should -Not -Match 'not in TrustedPublisher store' + } + + It 'passes a self-signed signer only when it is both a trust anchor and TrustedPublisher' { + $modDir = New-TestModule -Name 'SelfSignedRootAndPublisher' -WithManifest -Sign + Add-TestCertificateToStore -Store TrustedPublisher -Certificate $script:Cert + + $result = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:Cert -RequireTrustedPublisher + $result.Valid | Should -BeTrue + ($result.Files | Where-Object RequiredByPolicy | Select-Object -First 1).IsTrustedPublisher | Should -BeTrue + } + + It 'requires the CA-signed leaf signer, not root or intermediate, in TrustedPublisher' { + $modDir = New-ChainSignedTestModule -Name 'JordanLeafPublisher' + + Add-TestCertificateToStore -Store TrustedPublisher -Certificate $script:Chain.Root + $rootPublisher = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:Chain.Root -RequireTrustedPublisher + $rootPublisher.Valid | Should -BeFalse + $rootFile = $rootPublisher.Files | Where-Object RequiredByPolicy | Select-Object -First 1 + $rootFile.IsTrustedPublisher | Should -BeFalse + $rootFile.FailureReason | Should -Match 'not in TrustedPublisher store' + + Clear-TestPublisherStores + Add-TestCertificateToStore -Store TrustedPublisher -Certificate $script:Chain.Intermediate + $intermediatePublisher = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:Chain.Root -RequireTrustedPublisher + $intermediatePublisher.Valid | Should -BeFalse + $intermediateFile = $intermediatePublisher.Files | Where-Object RequiredByPolicy | Select-Object -First 1 + $intermediateFile.IsTrustedPublisher | Should -BeFalse + $intermediateFile.FailureReason | Should -Match 'not in TrustedPublisher store' + + Clear-TestPublisherStores + Add-TestCertificateToStore -Store TrustedPublisher -Certificate $script:Chain.Leaf + $leafPublisher = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:Chain.Root -RequireTrustedPublisher + $leafPublisher.Valid | Should -BeTrue + ($leafPublisher.Files | Where-Object RequiredByPolicy | Select-Object -First 1).IsTrustedPublisher | Should -BeTrue + } + + It 'accepts TrustedPublisher from CurrentUser or LocalMachine for the leaf signer' { + $modDir = New-ChainSignedTestModule -Name 'JordanPublisherScopes' + + Add-TestCertificateToStore -Scope LocalMachine -Store TrustedPublisher -Certificate $script:Chain.Leaf + $result = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:Chain.Root -RequireTrustedPublisher + $result.Valid | Should -BeTrue + ($result.Files | Where-Object RequiredByPolicy | Select-Object -First 1).IsTrustedPublisher | Should -BeTrue + } + + It 'does not accept the leaf signer as a root trust substitute' { + $modDir = New-ChainSignedTestModule -Name 'JordanLeafNotRoot' + Add-TestCertificateToStore -Store TrustedPublisher -Certificate $script:Chain.Leaf + + $result = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:Chain.Leaf -RequireTrustedPublisher + $result.Valid | Should -BeFalse + ($result.Files | Where-Object RequiredByPolicy | Select-Object -First 1).IsTrustedPublisher | Should -BeTrue + ($result.Files | Where-Object RequiredByPolicy | Select-Object -First 1).FailureReason | Should -Not -Match 'not in TrustedPublisher store' + } + + It 'does not accept the intermediate as a root trust substitute' { + $modDir = New-ChainSignedTestModule -Name 'JordanIntermediateNotRoot' + Add-TestCertificateToStore -Store TrustedPublisher -Certificate $script:Chain.Leaf + + $result = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:Chain.Intermediate -RequireTrustedPublisher + $result.Valid | Should -BeFalse + ($result.Files | Where-Object RequiredByPolicy | Select-Object -First 1).IsTrustedPublisher | Should -BeTrue + ($result.Files | Where-Object RequiredByPolicy | Select-Object -First 1).FailureReason | Should -Not -Match 'not in TrustedPublisher store' + } + + It 'blocks a Disallowed leaf signer even when chain and TrustedPublisher checks pass' { + $modDir = New-ChainSignedTestModule -Name 'JordanDisallowed' + Add-TestCertificateToStore -Store TrustedPublisher -Certificate $script:Chain.Leaf + Add-TestCertificateToStore -Store Disallowed -Certificate $script:Chain.Leaf + + $result = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:Chain.Root -RequireTrustedPublisher + $result.Valid | Should -BeFalse + $file = $result.Files | Where-Object RequiredByPolicy | Select-Object -First 1 + $file.IsTrustedPublisher | Should -BeTrue + $file.IsDisallowedPublisher | Should -BeTrue + $file.FailureReason | Should -Match 'Disallowed store' + } + + It 'blocks a Disallowed leaf signer even when TrustedPublisher is not required' { + $modDir = New-ChainSignedTestModule -Name 'JordanDisallowedNoPublisher' + Add-TestCertificateToStore -Store Disallowed -Certificate $script:Chain.Leaf + + $result = Test-PsignModule -Path $modDir -Policy AllSigned -TrustedCertificate $script:Chain.Root + $result.Valid | Should -BeFalse + $file = $result.Files | Where-Object RequiredByPolicy | Select-Object -First 1 + $file.IsTrustedPublisher | Should -BeFalse + $file.IsDisallowedPublisher | Should -BeTrue + $file.FailureReason | Should -Match 'Disallowed store' + } +} + Describe 'Test-PsignModule tamper detection' { It 'detects tampered file as HashMismatch' { $modDir = New-TestModule -Name 'Tampered' -WithManifest -Sign diff --git a/crates/psign-authenticode-trust/src/chain.rs b/crates/psign-authenticode-trust/src/chain.rs index 85810f3..e2f86d2 100644 --- a/crates/psign-authenticode-trust/src/chain.rs +++ b/crates/psign-authenticode-trust/src/chain.rs @@ -22,14 +22,6 @@ pub fn issuer_chain_excluding_leaf<'a>( return Ok(Vec::new()); } - // Check if the leaf itself is already trusted (thumbprint in anchor store) - if let Some(store) = anchors - && let Ok(thumb) = cert_sha1_thumbprint(leaf) - && store.contains_thumbprint(&thumb) - { - return Ok(Vec::new()); - } - let mut out: Vec<&'a Cert> = Vec::new(); let mut issuer_dn = leaf.issuer_name(); let mut steps = 0usize; @@ -97,14 +89,6 @@ pub fn issuer_chain_excluding_leaf_online( return Ok(Vec::new()); } - // Check if the leaf itself is already trusted - if let Some(store) = anchors - && let Ok(thumb) = cert_sha1_thumbprint(leaf) - && store.contains_thumbprint(&thumb) - { - return Ok(Vec::new()); - } - let mut out = Vec::new(); let mut current = leaf.clone(); let mut steps = 0usize; @@ -237,6 +221,20 @@ mod tests { assert_eq!(chain[0].subject_name(), ca.subject_name()); } + #[test] + fn non_self_signed_leaf_anchor_does_not_terminate_chain() { + let (ca, leaf) = synthetic_ca_and_leaf(); + let mut anchors = AnchorStore::empty(); + anchors + .merge_cert_thumbprints(std::slice::from_ref(&leaf)) + .expect("anchors"); + + let pool = vec![ca.clone(), leaf.clone()]; + let chain = issuer_chain_excluding_leaf(&leaf, &pool, Some(&anchors)).expect("chain"); + assert_eq!(chain.len(), 1); + assert_eq!(chain[0].subject_name(), ca.subject_name()); + } + #[test] fn merge_unique_drops_duplicate_thumbprints() { let (_, leaf) = synthetic_ca_and_leaf(); diff --git a/crates/psign-authenticode-trust/src/trust_pkcs7.rs b/crates/psign-authenticode-trust/src/trust_pkcs7.rs index a7d3ff0..d2c6428 100644 --- a/crates/psign-authenticode-trust/src/trust_pkcs7.rs +++ b/crates/psign-authenticode-trust/src/trust_pkcs7.rs @@ -86,6 +86,12 @@ fn verify_trust_chain_verbose( ); } + if leaf.subject_name() == leaf.issuer_name() + && cert_sha1_thumbprint(leaf).as_ref().ok() == Some(root_thumb) + { + return Ok(()); + } + leaf.verifier() .chain(chain_vec.iter().copied()) .exact_date(verification_instant) diff --git a/docs/portable-powershell-module.md b/docs/portable-powershell-module.md index a061f06..616ea90 100644 --- a/docs/portable-powershell-module.md +++ b/docs/portable-powershell-module.md @@ -52,6 +52,19 @@ When trust is requested, the output object's `TrustStatus` is `Valid` or `NotTru Trust verification is offline by default. `-OnlineAia` enables issuer retrieval, `-OnlineOcsp` enables OCSP checks, and `-RevocationMode Off|BestEffort|Require` controls revocation enforcement in the portable trust engine. +## PowerShell execution-policy publisher trust + +`Test-PsignModule -RequireTrustedPublisher` models the Windows PowerShell execution-policy behavior documented in Jordan Borean's [PowerShell code-signing notes](https://gist.github.com/jborean93/f9029a6561916e368bd23fc47757b4c8). In portable mode, the file-backed certificate store maps the relevant Windows stores as: + +| Windows store | Portable store | Purpose | +| --- | --- | --- | +| `Cert:\CurrentUser\TrustedPublisher` | `pcert:\CurrentUser\TrustedPublisher` | Trusted leaf signing certificates | +| `Cert:\LocalMachine\TrustedPublisher` | `pcert:\LocalMachine\TrustedPublisher` | Machine-wide trusted leaf signing certificates | +| `Cert:\CurrentUser\Disallowed` | `pcert:\CurrentUser\Disallowed` | Leaf signers rejected by "Never run" | +| `Cert:\LocalMachine\Disallowed` | `pcert:\LocalMachine\Disallowed` | Machine-wide disallowed signers | + +Publisher trust and chain trust are separate, matching the Windows behavior in those notes: the final signing certificate must be in `TrustedPublisher`, and the signature chain must still terminate in a trusted root supplied through `-TrustedCertificate`, `-TrustedCertificatePath`, `-AnchorDirectory`, `-AuthRootCab`, or the AuthRoot cache. `pcert:\...\Trust` is not used as a trusted-publisher fallback. + ## Supported portable formats The current PowerShell test suite covers command metadata compatibility plus signing and validation through the module surface for: diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignSignatureCommand.cs index ef2da97..18abd26 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/GetPsignSignatureCommand.cs @@ -9,7 +9,6 @@ namespace Devolutions.Psign.PowerShell.Cmdlets; [Cmdlet(VerbsCommon.Get, "PsignSignature", DefaultParameterSetName = FilePathParameterSet)] -[Alias("Get-PortableSignature")] [OutputType(typeof(PortableSignature))] public sealed class GetPsignSignatureCommand : PSCmdlet { diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/ProtectPsignModuleCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/ProtectPsignModuleCommand.cs index 9471da7..40f28c6 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/ProtectPsignModuleCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/ProtectPsignModuleCommand.cs @@ -17,10 +17,16 @@ namespace Devolutions.Psign.PowerShell.Cmdlets; [OutputType(typeof(PsignModuleSigningResult))] public sealed class ProtectPsignModuleCommand : PSCmdlet { - [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, HelpMessage = "Path to the PowerShell module directory to sign.")] - [Alias("ModulePath", "PSPath")] + private const string PathParameterSet = "Path"; + private const string InputObjectParameterSet = "InputObject"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, ParameterSetName = PathParameterSet, HelpMessage = "Path to the PowerShell module directory to sign.")] + [Alias("ModulePath", "PSPath", "InstalledLocation", "ModuleBase")] public string Path { get; set; } = string.Empty; + [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = InputObjectParameterSet, HelpMessage = "PowerShell module information returned by Get-Module.")] + public PSModuleInfo InputObject { get; set; } = null!; + // Local certificate signing [Parameter] public X509Certificate2? Certificate { get; set; } @@ -136,31 +142,11 @@ public sealed class ProtectPsignModuleCommand : PSCmdlet protected override void ProcessRecord() { - string resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(Path); - - string moduleDir; - string? manifestPath; - - if (Directory.Exists(resolvedPath)) - { - moduleDir = resolvedPath; - manifestPath = FindManifest(moduleDir); - } - else if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase)) - { - manifestPath = resolvedPath; - moduleDir = System.IO.Path.GetDirectoryName(resolvedPath)!; - } - else + if (!TryResolveModule(out string moduleDir, out string? manifestPath, out string moduleName)) { - ThrowTerminatingError(new ErrorRecord( - new DirectoryNotFoundException($"Module path not found: {resolvedPath}"), - "ModulePathNotFound", ErrorCategory.ObjectNotFound, resolvedPath)); return; } - string moduleName = System.IO.Path.GetFileName(moduleDir); - // Discover files to sign var filesToSign = DiscoverSignableFiles(moduleDir, manifestPath); @@ -226,6 +212,121 @@ protected override void ProcessRecord() }); } + private bool TryResolveModule(out string moduleDir, out string? manifestPath, out string moduleName) + { + moduleDir = string.Empty; + manifestPath = null; + moduleName = string.Empty; + + if (ParameterSetName == InputObjectParameterSet) + { + return TryResolveModuleInfo(InputObject, out moduleDir, out manifestPath, out moduleName); + } + + return TryResolvePath( + Path, + targetObject: Path, + moduleNameOverride: null, + writeTerminatingError: true, + out moduleDir, + out manifestPath, + out moduleName); + } + + private bool TryResolvePath( + string input, + object targetObject, + string? moduleNameOverride, + bool writeTerminatingError, + out string moduleDir, + out string? manifestPath, + out string moduleName) + { + moduleDir = string.Empty; + manifestPath = null; + moduleName = string.Empty; + string? resolvedPath = null; + Exception? pathResolutionException = null; + + try + { + resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(input); + } + catch (Exception ex) when (ex is ItemNotFoundException + or System.Management.Automation.DriveNotFoundException + or ProviderNotFoundException + or PSArgumentException + or NotSupportedException) + { + pathResolutionException = ex; + } + + if (resolvedPath is not null) + { + if (Directory.Exists(resolvedPath)) + { + moduleDir = resolvedPath; + manifestPath = FindManifest(moduleDir); + moduleName = moduleNameOverride ?? GetModuleName(moduleDir, manifestPath); + return true; + } + + if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase)) + { + manifestPath = resolvedPath; + moduleDir = System.IO.Path.GetDirectoryName(resolvedPath)!; + moduleName = moduleNameOverride ?? GetModuleName(moduleDir, manifestPath); + return true; + } + } + + string message = pathResolutionException is null + ? $"Module path not found: {resolvedPath ?? input}" + : $"Module path not found: {input}. {pathResolutionException.Message}"; + var error = new ErrorRecord( + new DirectoryNotFoundException(message), + "ModulePathNotFound", ErrorCategory.ObjectNotFound, targetObject); + + if (writeTerminatingError) + { + ThrowTerminatingError(error); + } + else + { + WriteError(error); + } + + return false; + } + + private bool TryResolveModuleInfo(PSModuleInfo module, out string moduleDir, out string? manifestPath, out string moduleName) + { + moduleDir = module.ModuleBase; + moduleName = module.Name; + manifestPath = null; + + if (!Directory.Exists(moduleDir)) + { + WriteError(new ErrorRecord( + new DirectoryNotFoundException($"Module path not found: {moduleDir}"), + "ModulePathNotFound", ErrorCategory.ObjectNotFound, module)); + return false; + } + + manifestPath = module.Path.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase) + ? module.Path + : FindManifest(moduleDir); + return true; + } + + private static string GetModuleName(string moduleDir, string? manifestPath) + { + if (manifestPath is not null) + return System.IO.Path.GetFileNameWithoutExtension(manifestPath); + + return System.IO.Path.GetFileName(moduleDir); + } + private List<(string RelativePath, ModuleFileRole Role)> DiscoverSignableFiles(string moduleDir, string? manifestPath) { var files = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPsignSignatureCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPsignSignatureCommand.cs index 1a994e8..52dfa8d 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPsignSignatureCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/SetPsignSignatureCommand.cs @@ -11,7 +11,6 @@ namespace Devolutions.Psign.PowerShell.Cmdlets; [Cmdlet(VerbsCommon.Set, "PsignSignature", SupportsShouldProcess = true, DefaultParameterSetName = FilePathParameterSet)] -[Alias("Set-PortableSignature")] [OutputType(typeof(PortableSignature))] public sealed class SetPsignSignatureCommand : PSCmdlet { @@ -208,42 +207,42 @@ private void SignContent(string sourcePathOrExtension) return; } - PortableSignResponse response = PsignNative.Sign(new PortableSignRequest - { - Path = tempPath, - HashAlgorithm = HashAlgorithm, - CertificatePath = CertificatePath is null - ? null - : SessionState.Path.GetUnresolvedProviderPathFromPSPath(CertificatePath), - PrivateKeyPath = PrivateKeyPath is null - ? null - : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PrivateKeyPath), - CertificateDerBase64 = GetCertificateDerBase64(), - PrivateKeyDerBase64 = GetPrivateKeyDerBase64(), - PfxPath = PfxPath is null - ? null - : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath), - PfxPassword = Password is null ? null : SecureStringToString(Password), - ChainCertificatePaths = GetChainCertificatePaths(), - ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), - TimestampServer = TimestampServer, - TimestampHashAlgorithm = TimestampServer is null ? null : TimestampHashAlgorithm, - AzureKeyVaultUrl = AzureKeyVaultUrl, - AzureKeyVaultCertificate = AzureKeyVaultCertificate, - AzureKeyVaultAccessToken = AzureKeyVaultAccessToken, - AzureKeyVaultClientId = AzureKeyVaultClientId, - AzureKeyVaultClientSecret = AzureKeyVaultClientSecret, - AzureKeyVaultTenantId = AzureKeyVaultTenantId, - AzureKeyVaultManagedIdentity = AzureKeyVaultManagedIdentity.IsPresent ? true : null, - ArtifactSigningEndpoint = ArtifactSigningEndpoint, - ArtifactSigningAccountName = ArtifactSigningAccountName, - ArtifactSigningProfileName = ArtifactSigningProfileName, - ArtifactSigningAccessToken = ArtifactSigningAccessToken, - ArtifactSigningManagedIdentity = ArtifactSigningManagedIdentity.IsPresent ? true : null, - ArtifactSigningTenantId = ArtifactSigningTenantId, - ArtifactSigningClientId = ArtifactSigningClientId, - ArtifactSigningClientSecret = ArtifactSigningClientSecret, - }); + PortableSignResponse response = PsignNative.Sign(new PortableSignRequest + { + Path = tempPath, + HashAlgorithm = HashAlgorithm, + CertificatePath = CertificatePath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(CertificatePath), + PrivateKeyPath = PrivateKeyPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PrivateKeyPath), + CertificateDerBase64 = GetCertificateDerBase64(), + PrivateKeyDerBase64 = GetPrivateKeyDerBase64(), + PfxPath = PfxPath is null + ? null + : SessionState.Path.GetUnresolvedProviderPathFromPSPath(PfxPath), + PfxPassword = Password is null ? null : SecureStringToString(Password), + ChainCertificatePaths = GetChainCertificatePaths(), + ChainCertificatesDerBase64 = GetChainCertificatesDerBase64(), + TimestampServer = TimestampServer, + TimestampHashAlgorithm = TimestampServer is null ? null : TimestampHashAlgorithm, + AzureKeyVaultUrl = AzureKeyVaultUrl, + AzureKeyVaultCertificate = AzureKeyVaultCertificate, + AzureKeyVaultAccessToken = AzureKeyVaultAccessToken, + AzureKeyVaultClientId = AzureKeyVaultClientId, + AzureKeyVaultClientSecret = AzureKeyVaultClientSecret, + AzureKeyVaultTenantId = AzureKeyVaultTenantId, + AzureKeyVaultManagedIdentity = AzureKeyVaultManagedIdentity.IsPresent ? true : null, + ArtifactSigningEndpoint = ArtifactSigningEndpoint, + ArtifactSigningAccountName = ArtifactSigningAccountName, + ArtifactSigningProfileName = ArtifactSigningProfileName, + ArtifactSigningAccessToken = ArtifactSigningAccessToken, + ArtifactSigningManagedIdentity = ArtifactSigningManagedIdentity.IsPresent ? true : null, + ArtifactSigningTenantId = ArtifactSigningTenantId, + ArtifactSigningClientId = ArtifactSigningClientId, + ArtifactSigningClientSecret = ArtifactSigningClientSecret, + }); response.Signature.SourcePathOrExtension = sourcePathOrExtension; response.Signature.Content = File.ReadAllBytes(tempPath); WriteObject(response.Signature); diff --git a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignModuleCommand.cs b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignModuleCommand.cs index b713b83..27e632d 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignModuleCommand.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Cmdlets/TestPsignModuleCommand.cs @@ -14,21 +14,27 @@ namespace Devolutions.Psign.PowerShell.Cmdlets; /// to a given execution policy (AllSigned or RemoteSigned). Simulates the checks /// that PowerShell's engine would perform during Import-Module. /// -[Cmdlet(VerbsDiagnostic.Test, "PsignModule")] +[Cmdlet(VerbsDiagnostic.Test, "PsignModule", DefaultParameterSetName = PathParameterSet)] [OutputType(typeof(PsignModuleValidationResult))] public sealed class TestPsignModuleCommand : PSCmdlet { - [Parameter(Mandatory = true, Position = 0, ValueFromPipeline = true, ValueFromPipelineByPropertyName = true, HelpMessage = "Path to the PowerShell module directory to validate.")] - [Alias("ModulePath", "PSPath")] + private const string PathParameterSet = "Path"; + private const string InputObjectParameterSet = "InputObject"; + + [Parameter(Mandatory = true, Position = 0, ValueFromPipelineByPropertyName = true, ParameterSetName = PathParameterSet, HelpMessage = "Path to the PowerShell module directory to validate.")] + [Alias("ModulePath", "PSPath", "InstalledLocation", "ModuleBase")] public string Path { get; set; } = string.Empty; + [Parameter(Mandatory = true, ValueFromPipeline = true, ParameterSetName = InputObjectParameterSet, HelpMessage = "PowerShell module information returned by Get-Module or Get-InstalledModule.")] + public object InputObject { get; set; } = null!; + [Parameter(Position = 1)] [ValidateSet("AllSigned", "RemoteSigned")] public PsignSigningPolicy Policy { get; set; } = PsignSigningPolicy.AllSigned; /// /// When set, also verifies that each signer's leaf certificate is in the - /// TrustedPublisher store (pcert:\CurrentUser\Trust or pcert:\LocalMachine\Trust). + /// TrustedPublisher store (pcert:\CurrentUser\TrustedPublisher or pcert:\LocalMachine\TrustedPublisher). /// [Parameter] public SwitchParameter RequireTrustedPublisher { get; set; } @@ -69,32 +75,11 @@ public sealed class TestPsignModuleCommand : PSCmdlet protected override void ProcessRecord() { - string resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(Path); - - // Find the manifest - string moduleDir; - string? manifestPath; - - if (Directory.Exists(resolvedPath)) - { - moduleDir = resolvedPath; - manifestPath = FindManifest(moduleDir); - } - else if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase)) + if (!TryResolveModule(out string moduleDir, out string? manifestPath, out string moduleName)) { - manifestPath = resolvedPath; - moduleDir = System.IO.Path.GetDirectoryName(resolvedPath)!; - } - else - { - WriteError(new ErrorRecord( - new DirectoryNotFoundException($"Module path not found: {resolvedPath}"), - "ModulePathNotFound", ErrorCategory.ObjectNotFound, resolvedPath)); return; } - string moduleName = System.IO.Path.GetFileName(moduleDir); - // Parse manifest to discover referenced files var fileRoles = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -138,6 +123,7 @@ protected override void ProcessRecord() // Load trusted publisher thumbprints var trustedPublishers = LoadTrustedPublishers(); + var disallowedPublishers = LoadDisallowedPublishers(); // Validate each file var results = new List(); @@ -159,7 +145,7 @@ protected override void ProcessRecord() } bool requiredByPolicy = IsRequiredByPolicy(role, relativePath); - var fileResult = ValidateFile(fullPath, relativePath, role, requiredByPolicy, trustedPublishers); + var fileResult = ValidateFile(fullPath, relativePath, role, requiredByPolicy, trustedPublishers, disallowedPublishers); results.Add(fileResult); } @@ -187,6 +173,161 @@ protected override void ProcessRecord() }); } + private bool TryResolveModule(out string moduleDir, out string? manifestPath, out string moduleName) + { + moduleDir = string.Empty; + manifestPath = null; + moduleName = string.Empty; + + if (ParameterSetName == InputObjectParameterSet) + { + return TryResolveInputObject(InputObject, out moduleDir, out manifestPath, out moduleName); + } + + return TryResolvePath(Path, targetObject: Path, moduleNameOverride: null, out moduleDir, out manifestPath, out moduleName); + } + + private bool TryResolveInputObject(object inputObject, out string moduleDir, out string? manifestPath, out string moduleName) + { + if (inputObject is PSModuleInfo module) + { + return TryResolveModuleInfo(module, out moduleDir, out manifestPath, out moduleName); + } + + if (inputObject is string path) + { + return TryResolvePath(path, inputObject, moduleNameOverride: null, out moduleDir, out manifestPath, out moduleName); + } + + if (inputObject is FileSystemInfo fileSystemInfo) + { + return TryResolvePath(fileSystemInfo.FullName, inputObject, moduleNameOverride: null, out moduleDir, out manifestPath, out moduleName); + } + + var psObject = PSObject.AsPSObject(inputObject); + string? installedLocation = GetStringProperty(psObject, "InstalledLocation"); + if (!string.IsNullOrWhiteSpace(installedLocation)) + { + return TryResolvePath( + installedLocation, + inputObject, + moduleNameOverride: GetStringProperty(psObject, "Name"), + out moduleDir, + out manifestPath, + out moduleName); + } + + string? moduleBase = GetStringProperty(psObject, "ModuleBase"); + if (!string.IsNullOrWhiteSpace(moduleBase)) + { + return TryResolvePath( + moduleBase, + inputObject, + moduleNameOverride: GetStringProperty(psObject, "Name"), + out moduleDir, + out manifestPath, + out moduleName); + } + + moduleDir = string.Empty; + manifestPath = null; + moduleName = string.Empty; + WriteError(new ErrorRecord( + new ArgumentException("Pipeline input must be a module path, PSModuleInfo, or an object with InstalledLocation or ModuleBase."), + "UnsupportedModuleInputObject", ErrorCategory.InvalidArgument, inputObject)); + return false; + } + + private bool TryResolvePath( + string input, + object targetObject, + string? moduleNameOverride, + out string moduleDir, + out string? manifestPath, + out string moduleName) + { + moduleDir = string.Empty; + manifestPath = null; + moduleName = string.Empty; + string? resolvedPath = null; + Exception? pathResolutionException = null; + + try + { + resolvedPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath(input); + } + catch (Exception ex) when (ex is ItemNotFoundException + or System.Management.Automation.DriveNotFoundException + or ProviderNotFoundException + or PSArgumentException + or NotSupportedException) + { + pathResolutionException = ex; + } + + if (resolvedPath is not null) + { + if (Directory.Exists(resolvedPath)) + { + moduleDir = resolvedPath; + manifestPath = FindManifest(moduleDir); + moduleName = moduleNameOverride ?? GetModuleName(moduleDir, manifestPath); + return true; + } + + if (File.Exists(resolvedPath) && resolvedPath.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase)) + { + manifestPath = resolvedPath; + moduleDir = System.IO.Path.GetDirectoryName(resolvedPath)!; + moduleName = moduleNameOverride ?? GetModuleName(moduleDir, manifestPath); + return true; + } + } + + string message = pathResolutionException is null + ? $"Module path not found: {resolvedPath ?? input}" + : $"Module path not found: {input}. {pathResolutionException.Message}"; + + WriteError(new ErrorRecord( + new DirectoryNotFoundException(message), + "ModulePathNotFound", ErrorCategory.ObjectNotFound, targetObject)); + return false; + } + + private bool TryResolveModuleInfo(PSModuleInfo module, out string moduleDir, out string? manifestPath, out string moduleName) + { + moduleDir = module.ModuleBase; + moduleName = module.Name; + manifestPath = null; + + if (!Directory.Exists(moduleDir)) + { + WriteError(new ErrorRecord( + new DirectoryNotFoundException($"Module path not found: {moduleDir}"), + "ModulePathNotFound", ErrorCategory.ObjectNotFound, module)); + return false; + } + + manifestPath = module.Path.EndsWith(".psd1", StringComparison.OrdinalIgnoreCase) + ? module.Path + : FindManifest(moduleDir); + return true; + } + + private static string? GetStringProperty(PSObject psObject, string name) + { + object? value = psObject.Properties[name]?.Value; + return value as string; + } + + private static string GetModuleName(string moduleDir, string? manifestPath) + { + if (manifestPath is not null) + return System.IO.Path.GetFileNameWithoutExtension(manifestPath); + + return System.IO.Path.GetFileName(moduleDir); + } + private bool IsRequiredByPolicy(ModuleFileRole role, string relativePath) { // PowerShell engine behavior: @@ -215,7 +356,7 @@ private bool IsRequiredByPolicy(ModuleFileRole role, string relativePath) private PsignModuleFileResult ValidateFile( string fullPath, string relativePath, ModuleFileRole role, - bool requiredByPolicy, HashSet trustedPublishers) + bool requiredByPolicy, HashSet trustedPublishers, HashSet disallowedPublishers) { string ext = System.IO.Path.GetExtension(fullPath); @@ -258,6 +399,8 @@ private PsignModuleFileResult ValidateFile( string? signerThumbprint = sig.SignerCertificate?.Thumbprint; bool isTrustedPublisher = signerThumbprint is not null && trustedPublishers.Contains(signerThumbprint); + bool isDisallowedPublisher = signerThumbprint is not null + && disallowedPublishers.Contains(signerThumbprint); bool passes; string? failureReason = null; @@ -279,6 +422,13 @@ private PsignModuleFileResult ValidateFile( _ => $"Signature validation failed: {effectiveStatus}", }; } + else if (isDisallowedPublisher) + { + passes = false; + failureReason = signerThumbprint is not null + ? $"Signer {sig.SignerCertificate?.Subject} ({signerThumbprint}) is in Disallowed store" + : "No signer certificate found"; + } else if (RequireTrustedPublisher.IsPresent && !isTrustedPublisher) { passes = false; @@ -301,6 +451,7 @@ private PsignModuleFileResult ValidateFile( SignerSubject = sig.SignerCertificate?.Subject, SignerThumbprint = signerThumbprint, IsTrustedPublisher = isTrustedPublisher, + IsDisallowedPublisher = isDisallowedPublisher, Passes = passes, FailureReason = failureReason, }; @@ -345,16 +496,28 @@ private PortableGetSignatureRequest CreateVerifyRequest(string path) private HashSet LoadTrustedPublishers() { - var thumbprints = new HashSet(StringComparer.OrdinalIgnoreCase); if (!RequireTrustedPublisher.IsPresent) - return thumbprints; + return new HashSet(StringComparer.OrdinalIgnoreCase); - string baseDir = CertStorePathHelper.ResolveBaseDirectory(); + // Match Windows PowerShell publisher trust: Cert:\CurrentUser\TrustedPublisher + // and Cert:\LocalMachine\TrustedPublisher contain the trusted leaf signing certs. + return LoadStoreThumbprints("TrustedPublisher"); + } + + private static HashSet LoadDisallowedPublishers() + { + // Match PowerShell's "Never run" behavior: the leaf signer is added to + // Cert:\CurrentUser\Disallowed and subsequent policy checks are blocked. + return LoadStoreThumbprints("Disallowed"); + } - // Check both CurrentUser and LocalMachine Trust stores + private static HashSet LoadStoreThumbprints(string storeName) + { + var thumbprints = new HashSet(StringComparer.OrdinalIgnoreCase); + string baseDir = CertStorePathHelper.ResolveBaseDirectory(); foreach (string scope in new[] { "CurrentUser", "LocalMachine" }) { - string storePath = System.IO.Path.Combine(baseDir, scope, "Trust"); + string storePath = System.IO.Path.Combine(baseDir, scope, storeName); if (!Directory.Exists(storePath)) continue; diff --git a/dotnet/Devolutions.Psign.PowerShell/Models/PsignModuleValidation.cs b/dotnet/Devolutions.Psign.PowerShell/Models/PsignModuleValidation.cs index efe8419..4c2e2a2 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Models/PsignModuleValidation.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Models/PsignModuleValidation.cs @@ -69,9 +69,12 @@ public sealed class PsignModuleFileResult /// Thumbprint of the signing certificate leaf. public string? SignerThumbprint { get; init; } - /// Whether the signer is in the TrustedPublisher (Trust) store. + /// Whether the signer is in the TrustedPublisher store. public bool IsTrustedPublisher { get; init; } + /// Whether the signer is in the Disallowed store. + public bool IsDisallowedPublisher { get; init; } + /// Whether this file passes the policy check. public bool Passes { get; init; } diff --git a/dotnet/Devolutions.Psign.PowerShell/Provider/CertStorePathHelper.cs b/dotnet/Devolutions.Psign.PowerShell/Provider/CertStorePathHelper.cs index 80a4de3..37fcbd2 100644 --- a/dotnet/Devolutions.Psign.PowerShell/Provider/CertStorePathHelper.cs +++ b/dotnet/Devolutions.Psign.PowerShell/Provider/CertStorePathHelper.cs @@ -12,7 +12,7 @@ internal static class CertStorePathHelper { internal static readonly string[] WellKnownScopes = ["CurrentUser", "LocalMachine"]; - internal static readonly string[] WellKnownStores = ["MY", "Root", "CA", "Trust", "Disallowed"]; + internal static readonly string[] WellKnownStores = ["MY", "Root", "CA", "Trust", "TrustedPublisher", "Disallowed"]; /// /// Resolve the base directory for the portable certificate store. @@ -60,6 +60,7 @@ internal static string NormalizeStoreName(string storeName) "root" => "Root", "ca" => "CA", "trust" => "Trust", + "trustedpublisher" => "TrustedPublisher", "disallowed" => "Disallowed", _ => trimmed, };