diff --git a/source/DSCResources/DSC_WSManListener/DSC_WSManListener.psm1 b/source/DSCResources/DSC_WSManListener/DSC_WSManListener.psm1 index 75631c8..f2b5410 100644 --- a/source/DSCResources/DSC_WSManListener/DSC_WSManListener.psm1 +++ b/source/DSCResources/DSC_WSManListener/DSC_WSManListener.psm1 @@ -133,8 +133,8 @@ function Get-TargetResource Listener if a thumbprint is not specified. .PARAMETER DN - This is a Distinguished Name component that will be used to identify the certificate to use - for the HTTPS WS-Man Listener if a thumbprint is not specified. + This is the BaseDN (path part of the full Distinguished Name) used to identify the certificate + to use for the HTTPS WS-Man Listener if a thumbprint is not specified. .PARAMETER CertificateThumbprint The Thumbprint of the certificate to use for the HTTPS WS-Man Listener. @@ -360,8 +360,8 @@ function Set-TargetResource Listener if a thumbprint is not specified. .PARAMETER DN - This is a Distinguished Name component that will be used to identify the certificate to use - for the HTTPS WS-Man Listener if a thumbprint is not specified. + This is the BaseDN (path part of the full Distinguished Name) used to identify the certificate + to use for the HTTPS WS-Man Listener if a thumbprint is not specified. .PARAMETER CertificateThumbprint The Thumbprint of the certificate to use for the HTTPS WS-Man Listener. @@ -589,8 +589,8 @@ function Get-DefaultPort Listener if a thumbprint is not specified. .PARAMETER DN - This is a Distinguished Name component that will be used to identify the certificate to use - for the HTTPS WS-Man Listener if a thumbprint is not specified. + This is the BaseDN (path part of the full Distinguished Name) used to identify the certificate + to use for the HTTPS WS-Man Listener if a thumbprint is not specified. .PARAMETER CertificateThumbprint The Thumbprint of the certificate to use for the HTTPS WS-Man Listener. @@ -602,7 +602,7 @@ function Find-Certificate param ( [Parameter()] - [System.String] + [System.String[]] $Issuer, [Parameter()] @@ -615,7 +615,7 @@ function Find-Certificate $MatchAlternate, [Parameter()] - [System.String] + [System.String[]] $DN, [Parameter()] @@ -624,122 +624,88 @@ function Find-Certificate [Parameter()] [System.String] - $Hostname + $Hostname = $env:ComputerName ) - [System.String] $thumbprint = '' + $private:PSDefaultParameterValues = @{ + 'Get-Childitem:Path' = 'Cert:\LocalMachine\My' + 'Get-ChildItem:SSLServerAuthentication' = $true + } - if ($PSBoundParameters.ContainsKey('CertificateThumbprint')) - { - Write-Verbose -Message ( @( - "$($MyInvocation.MyCommand): " - $($script:localizedData.FindCertificateByThumbprintMessage) ` - -f $CertificateThumbprint - ) -join '' ) + # This hashtable will represent the search criteria. It is later compiled + # into a FilterScript + [Hashtable] $X509Properties = @{} - $certificate = Get-ChildItem -Path Cert:\localmachine\my | Where-Object -FilterScript { - ($_.Thumbprint -eq $CertificateThumbprint) - } | Select-Object -First 1 + # If CertificateThumbprint is specified, that's all we need or care about. + # Else, convert the input parameters into matcher arrays in $X509Properties. + if ($CertificateThumbprint) { + $X509Properties.Thumbprint = $CertificateThumbprint } - else - { - # First try and find a certificate that is used to the FQDN of the machine - if ($SubjectFormat -in 'Both', 'FQDNOnly') + else { + [System.String] $FQDN = [System.Net.Dns]::GetHostByName($Hostname).HostName + + # SubjectFormat + switch ($SubjectFormat) { - # Lookup the certificate using the FQDN of the machine - if ([System.String]::IsNullOrEmpty($Hostname)) - { - $Hostname = [System.Net.Dns]::GetHostByName($ENV:computerName).Hostname + 'FQDNOnly' { [System.String] $CNs = $FQDN } + 'NameOnly' { [System.String] $CNs = $Hostname } + 'Both' { [System.String[]] $CNs = @($FQDN, $Hostname) } + } + + # DNSNameList via MatchAlternate + if ($MatchAlternate) { [System.String[]] $X509Properties.DNSNameList = @($CNs) } + + # BaseDNs via DN + if ($PSBoundParameters.ContainsKey('DN')) { + [System.String[]] $BaseDNs = foreach ($baseDN in $DN) { ", ${baseDN}" } + } else { + [System.String[]] $BaseDNs = @('') + } + + # Put it all together for a list of possible Subject names + [System.String[]] $X509Properties.Subject = foreach ($hostname in $CNs) { + foreach ($baseDN in $BaseDNs) { + "CN=${hostname}${baseDN}" } - $Subject = "CN=$Hostname" + } - if ($PSBoundParameters.ContainsKey('DN')) - { - $Subject = "$Subject, $DN" - } # if + if ($Issuer) { [System.String[]] $X509Properties.Issuer = @($Issuer) } + } - if ($MatchAlternate) - { - # Try and lookup the certificate using the subject and the alternate name - Write-Verbose -Message ( @( - "$($MyInvocation.MyCommand): " - $($script:localizedData.FindCertificateAlternateMessage) ` - -f $Subject, $Issuer, $Hostname - ) -join '' ) + # Compile the X509Properties object into a list of expressions + [System.String[]] $filterExpressions = foreach ($property in $X509Properties.GetEnumerator()) { + if ($property.Value.Length -GT 0) { + [System.String] $valueString = "'{0}'" -f ($property.Value -join "','") - $certificate = (Get-ChildItem -Path Cert:\localmachine\my | Where-Object -FilterScript { - ($_.Extensions.EnhancedKeyUsages.FriendlyName ` - -contains 'Server Authentication') -and - ($_.Issuer -eq $Issuer) -and - ($Hostname -in $_.DNSNameList.Unicode) -and - ($_.Subject -eq $Subject) - } | Select-Object -First 1) + switch ($property.Key) { + 'DNSNameList' { + '(Compare-Object $_.DNSNameList.Unicode ({0}) -PassThru -IncludeEqual -ExcludeDifferent) -gt 0' -f $valueString + } + default { + '$_.{0} -in {1}' -f $property.Key, $valueString + } } - else - { - # Try and lookup the certificate using the subject name - Write-Verbose -Message ( @( - "$($MyInvocation.MyCommand): " - $($script:localizedData.FindCertificateMessage) ` - -f $Subject, $Issuer - ) -join '' ) - $certificate = Get-ChildItem -Path Cert:\localmachine\my | Where-Object -FilterScript { - ($_.Extensions.EnhancedKeyUsages.FriendlyName ` - -contains 'Server Authentication') -and - ($_.Issuer -eq $Issuer) -and - ($_.Subject -eq $Subject) - } | Select-Object -First 1 - } # if } + } - if (-not $certificate ` - -and ($SubjectFormat -in 'Both', 'NameOnly')) - { - # If could not find an FQDN cert, try for one issued to the computer name - [System.String] $Hostname = $ENV:ComputerName - [System.String] $Subject = "CN=$Hostname" - - if ($PSBoundParameters.ContainsKey('DN')) - { - $Subject = "$Subject, $DN" - } # if + # Link the expressions + [System.String] $filterScriptString = $filterExpressions -join ' -and ' - if ($MatchAlternate) - { - # Try and lookup the certificate using the subject and the alternate name - Write-Verbose -Message ( @( - "$($MyInvocation.MyCommand): " - $($script:localizedData.FindCertificateAlternateMessage) ` - -f $Subject, $Issuer, $Hostname - ) -join '' ) + # Create the ScriptBlock + $filterScript = [System.Management.Automation.ScriptBlock]::Create($filterScriptString) - $certificate = Get-ChildItem -Path Cert:\localmachine\my | Where-Object -FilterScript { - ($_.Extensions.EnhancedKeyUsages.FriendlyName ` - -contains 'Server Authentication') -and - ($_.Issuer -eq $Issuer) -and - ($Hostname -in $_.DNSNameList.Unicode) -and - ($_.Subject -eq $Subject) - } | Select-Object -First 1 - } - else - { - # Try and lookup the certificate using the subject name - Write-Verbose -Message ( @( - "$($MyInvocation.MyCommand): " - $($script:localizedData.FindCertificateMessage) ` - -f $Subject, $Issuer - ) -join '' ) + # Execute our search + $certificateList = Get-ChildItem | Where-Object -FilterScript $filterScript - $certificate = Get-ChildItem -Path Cert:\localmachine\my | Where-Object -FilterScript { - ($_.Extensions.EnhancedKeyUsages.FriendlyName ` - -contains 'Server Authentication') -and - ($_.Issuer -eq $Issuer) -and - ($_.Subject -eq $Subject) - } | Select-Object -First 1 - } # if - } # if - } # if + # Sort and select to ensure deterministic behavior + if ($certificateList) + { + $certificate = $certificateList | Sort-Object @( + @{ Expression = { $X509Properties['Subject'].IndexOf($_.Subject) }; Descending = $true } + @{ Expression = { $X509Properties['Issuer'].IndexOf($_.Issuer) }; Descending = $false } + ) | Select-Object -First 1 + } if ($certificate) { diff --git a/source/DSCResources/DSC_WSManListener/DSC_WSManListener.schema.mof b/source/DSCResources/DSC_WSManListener/DSC_WSManListener.schema.mof index 52cbeea..a644443 100644 --- a/source/DSCResources/DSC_WSManListener/DSC_WSManListener.schema.mof +++ b/source/DSCResources/DSC_WSManListener/DSC_WSManListener.schema.mof @@ -8,7 +8,7 @@ class DSC_WSManListener : OMI_BaseResource [Write, Description("The Issuer of the certificate to use for the HTTPS WS-Man Listener if a thumbprint is not specified.")] String Issuer; [Write, Description("The format used to match the certificate subject to use for an HTTPS WS-Man Listener if a thumbprint is not specified."), ValueMap{"Both","FQDNOnly","NameOnly"}, Values{"Both","FQDNOnly","NameOnly"}] String SubjectFormat; [Write, Description("Should the FQDN/Name be used to also match the certificate alternate subject for an HTTPS WS-Man Listener if a thumbprint is not specified.")] Boolean MatchAlternate; - [Write, Description("This is a Distinguished Name component that will be used to identify the certificate to use for the HTTPS WS-Man Listener if a thumbprint is not specified.")] String DN; + [Write, Description("This is the BaseDN (base of the full Distinguished Name) used to identify the certificate to use for the HTTPS WS-Man Listener if a thumbprint is not specified.")] String DN; [Write, Description("The host name that a HTTPS WS-Man Listener will be bound to. If not specified it will default to the computer name of the node.")] String Hostname; [Read, Description("Returns true if the existing WS-Man Listener is enabled.")] Boolean Enabled; [Read, Description("The URL Prefix of the existing WS-Man Listener.")] String URLPrefix; diff --git a/tests/Integration/DSC_WSManListener.Integration.Tests.ps1 b/tests/Integration/DSC_WSManListener.Integration.Tests.ps1 index 033a1b8..1f7076b 100644 --- a/tests/Integration/DSC_WSManListener.Integration.Tests.ps1 +++ b/tests/Integration/DSC_WSManListener.Integration.Tests.ps1 @@ -91,8 +91,8 @@ try Remove-Item -Force $Hostname = ([System.Net.Dns]::GetHostByName($ENV:computerName).Hostname) - $DN = 'O=Contoso Inc, S=Pennsylvania, C=US' - $Issuer = "CN=$Hostname, $DN" + $BaseDN = 'O=Contoso Inc, S=Pennsylvania, C=US' + $Issuer = "CN=$Hostname, $BaseDN" # Create the certificate if ([System.Environment]::OSVersion.Version.Major -ge 10) @@ -146,7 +146,7 @@ try Issuer = $Issuer SubjectFormat = 'Both' MatchAlternate = $False - DN = $DN + BaseDN = $BaseDN Hostname = $Hostname } ) diff --git a/tests/Integration/DSC_WSManListener_Add_HTTPS.config.ps1 b/tests/Integration/DSC_WSManListener_Add_HTTPS.config.ps1 index d7d0eb6..fc739d1 100644 --- a/tests/Integration/DSC_WSManListener_Add_HTTPS.config.ps1 +++ b/tests/Integration/DSC_WSManListener_Add_HTTPS.config.ps1 @@ -10,7 +10,7 @@ Configuration DSC_WSManListener_Config_Add_HTTPS { Issuer = $Node.Issuer SubjectFormat = $Node.SubjectFormat MatchAlternate = $Node.MatchAlternate - DN = $Node.DN + DN = $Node.BaseDN } } } diff --git a/tests/Unit/DSC_WSManListener.Tests.ps1 b/tests/Unit/DSC_WSManListener.Tests.ps1 index c267098..3a3fc87 100644 --- a/tests/Unit/DSC_WSManListener.Tests.ps1 +++ b/tests/Unit/DSC_WSManListener.Tests.ps1 @@ -30,11 +30,14 @@ try $script:dscResourceName = 'DSC_WSManListener' # Create the Mock Objects that will be used for running tests - $mockFQDN = 'SERVER1.CONTOSO.COM' $mockCertificateThumbprint = '74FA31ADEA7FDD5333CED10910BFA6F665A1F2FC' - $mockHostName = $([System.Net.Dns]::GetHostByName($ENV:computerName).Hostname) + $mockCertificateThumbprintFQDN = 'A264CA7ADCC280077D401D95DCDEAD41F244F2529' + $mockCertificateThumbprintAlternateIssuer = '391A345E1DD2FF5CC62E5CA6938B3B7DF52A62052' + $mockHostName = ($env:ComputerName).ToLower() + $mockFQDN = [System.Net.Dns]::GetHostByName($env:ComputerName).Hostname $mockIssuer = 'CN=CONTOSO.COM Issuing CA, DC=CONTOSO, DC=COM' - $mockDN = 'O=Contoso Inc, S=Pennsylvania, C=US' + $mockIssuer2 = 'cn=Example CA,dc=example,dc=com' + $mockBaseDN = 'O=Contoso Inc, S=Pennsylvania, C=US' $mockCertificate = [PSObject] @{ Thumbprint = $mockCertificateThumbprint @@ -44,14 +47,30 @@ try DNSNameList = @{ Unicode = $mockHostName } } - $mockCertificateDN = [PSObject] @{ + $mockCertificateWithBaseDN = [PSObject] @{ Thumbprint = $mockCertificateThumbprint - Subject = "CN=$mockHostName, $mockDN" + Subject = "CN=$mockHostName, $mockBaseDN" Issuer = $mockIssuer Extensions = @{ EnhancedKeyUsages = @{ FriendlyName = 'Server Authentication' } } DNSNameList = @{ Unicode = $mockHostName } } + $mockCertificateWithFQDN = [PSObject] @{ + Thumbprint = $mockCertificateThumbprintFQDN + Subject = "CN=$mockFQDN" + Issuer = $mockIssuer + Extensions = @{ EnhancedKeyUsages = @{ FriendlyName = 'Server Authentication' } } + DNSNameList = @{ Unicode = $mockFQDN } + } + + $mockCertificateWithAlternateIssuer = [PSObject] @{ + Thumbprint = $mockCertificateThumbprintAlternateIssuer + Subject = "CN=$mockHostName" + Issuer = $mockIssuer2 + Extensions = @{ EnhancedKeyUsages = @{ FriendlyName = 'Server Authentication' } } + DNSNameList = @{ Unicode = $mockFQDN } + } + $mockListenerHTTP = [PSObject] @{ cfg = 'http://schemas.microsoft.com/wbem/wsman/1/config/listener' xsi = 'http://www.w3.org/2001/XMLSchema-instance' @@ -500,7 +519,7 @@ try Context 'CertificateThumbprint is passed and does exist' { Mock -CommandName Get-ChildItem -MockWith { - $mockCertificateDN + $mockCertificateWithBaseDN } It 'Should not throw error' { @@ -526,7 +545,7 @@ try -Issuer $mockIssuer ` -SubjectFormat 'Both' ` -MatchAlternate $True ` - -DN $mockDN ` + -DN $mockBaseDN ` -Verbose } | Should -Not -Throw } @@ -535,13 +554,13 @@ try } It 'Should call expected Mocks' { - Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 2 + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 } } Context 'SubjectFormat is Both, Certificate with DN Exists, DN passed' { Mock -CommandName Get-ChildItem -MockWith { - $mockCertificateDN + $mockCertificateWithBaseDN } It 'Should not throw error' { @@ -549,7 +568,7 @@ try -Issuer $mockIssuer ` -SubjectFormat 'Both' ` -MatchAlternate $True ` - -DN $mockDN ` + -DN $mockBaseDN ` -Verbose } | Should -Not -Throw } @@ -562,7 +581,7 @@ try } } - Context 'SubjectFormat is Both, Certificate without DN Exists, DN passed' { + Context 'SubjectFormat is Both, Certificate without Base DN Exists, DN passed' { Mock -CommandName Get-ChildItem -MockWith { $mockCertificate } @@ -572,7 +591,7 @@ try -Issuer $mockIssuer ` -SubjectFormat 'Both' ` -MatchAlternate $True ` - -DN $mockDN ` + -DN $mockBaseDN ` -Verbose } | Should -Not -Throw } @@ -581,7 +600,7 @@ try } It 'Should call expected Mocks' { - Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 2 + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 } } @@ -601,13 +620,13 @@ try } It 'Should call expected Mocks' { - Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 2 + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 } } - Context 'SubjectFormat is Both, Certificate with DN Exists, DN not passed' { + Context 'SubjectFormat is Both, Certificate with Base DN Exists, DN not passed' { Mock -CommandName Get-ChildItem -MockWith { - $mockCertificateDN + $mockCertificateWithBaseDN } It 'Should not throw error' { @@ -623,11 +642,11 @@ try } It 'Should call expected Mocks' { - Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 2 + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 } } - Context 'SubjectFormat is Both, Certificate without DN Exists, DN not passed' { + Context 'SubjectFormat is Both, Certificate without Base DN Exists, DN not passed' { Mock -CommandName Get-ChildItem -MockWith { $mockCertificate } @@ -648,6 +667,174 @@ try Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 } } + + Context 'SubjectFormat is Both, Multiple certificates exist, DN not passed' { + Mock -CommandName Get-ChildItem -MockWith { + $mockCertificate, $mockCertificateWithFQDN + } + + It 'Should not throw error' { + { $script:returnedCertificate = Find-Certificate ` + -Issuer $mockIssuer ` + -SubjectFormat 'Both' ` + -MatchAlternate $True ` + -Verbose } | Should -Not -Throw + } + + It 'Should return expected certificate' { + $script:returnedCertificate.Subject | Should -Be "CN=$mockFQDN" + } + + It 'Should call expected Mocks' { + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 + } + } + + Context 'Issuer is an Array, Primary certificate exists' { + Mock -CommandName Get-ChildItem -MockWith { + $mockCertificate + } + + It 'Should not throw error' { + { $script:returnedCertificate = Find-Certificate ` + -Issuer @($mockIssuer, $mockIssuer2) ` + -MatchAlternate $True ` + -Verbose } | Should -Not -Throw + } + + It 'Should return expected certificate' { + $script:returnedCertificate.Thumbprint | Should -Be $mockCertificateThumbprint + } + + It 'Should call expected Mocks' { + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 + } + } + + Context 'Issuer is an Array, Secondary certificate exists' { + Mock -CommandName Get-ChildItem -MockWith { + $mockCertificateWithAlternateIssuer + } + + It 'Should not throw error' { + { $script:returnedCertificate = Find-Certificate ` + -Issuer @($mockIssuer, $mockIssuer2) ` + -MatchAlternate $True ` + -Verbose } | Should -Not -Throw + } + + It 'Should return expected certificate' { + $script:returnedCertificate.Thumbprint | Should -Be $mockCertificateThumbprintAlternateIssuer + } + + It 'Should call expected Mocks' { + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 + } + } + + Context 'Issuer is an Array, Two matching certificate exist' { + Mock -CommandName Get-ChildItem -MockWith { + $mockCertificate, $mockCertificateWithAlternateIssuer + } + + It 'Should not throw error' { + { $script:returnedCertificate = Find-Certificate ` + -Issuer @($mockIssuer, $mockIssuer2) ` + -MatchAlternate $True ` + -Verbose } | Should -Not -Throw + } + + It 'Should return expected certificate' { + $script:returnedCertificate.Thumbprint | Should -Be $mockCertificateThumbprint + } + + It 'Should call expected Mocks' { + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 + } + } + + Context 'Issuer is an Array, Two matching certificate exist, Certificate order reversed' { + Mock -CommandName Get-ChildItem -MockWith { + $mockCertificateWithAlternateIssuer, $mockCertificate + } + + It 'Should not throw error' { + { $script:returnedCertificate = Find-Certificate ` + -Issuer @($mockIssuer, $mockIssuer2) ` + -MatchAlternate $True ` + -Verbose } | Should -Not -Throw + } + + It 'Should return expected certificate' { + $script:returnedCertificate.Thumbprint | Should -Be $mockCertificateThumbprint + } + + It 'Should call expected Mocks' { + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 + } + } + + Context 'Issuer is a reversed Array, Two matching certificate exist' { + Mock -CommandName Get-ChildItem -MockWith { + $mockCertificate, $mockCertificateWithAlternateIssuer + } + + It 'Should not throw error' { + { $script:returnedCertificate = Find-Certificate ` + -Issuer @($mockIssuer2, $mockIssuer) ` + -MatchAlternate $True ` + -Verbose } | Should -Not -Throw + } + + It 'Should return expected certificate' { + $script:returnedCertificate.Thumbprint | Should -Be $mockCertificateThumbprintAlternateIssuer + } + + It 'Should call expected Mocks' { + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 + } + } + + Context 'Issuer is an reversed Array, Two matching certificate exist, Certificate order reversed' { + Mock -CommandName Get-ChildItem -MockWith { + $mockCertificateWithAlternateIssuer, $mockCertificate + } + + It 'Should not throw error' { + { $script:returnedCertificate = Find-Certificate ` + -Issuer @($mockIssuer2, $mockIssuer) ` + -MatchAlternate $True ` + -Verbose } | Should -Not -Throw + } + + It 'Should return expected certificate' { + $script:returnedCertificate.Thumbprint | Should -Be $mockCertificateThumbprintAlternateIssuer + } + + It 'Should call expected Mocks' { + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 + } + } + + Context 'Issuer not specified, Two matching certificate exist' { + Mock -CommandName Get-ChildItem -MockWith { + $mockCertificate, $mockCertificateWithAlternateIssuer + } + + It 'Should not throw error' { + { $script:returnedCertificate = Find-Certificate ` + -MatchAlternate $True ` + -Verbose } | Should -Not -Throw + } + + It 'Should return expected certificate' { + $script:returnedCertificate.Thumbprint | Should -BeIn $mockCertificateThumbprint, $mockCertificateThumbprintAlternateIssuer + } + + It 'Should call expected Mocks' { + Assert-MockCalled -CommandName Get-ChildItem -Exactly -Times 1 + } + } } } }