modules/ISC.AdLdaps.psm1
|
#Requires -Version 5.1 Set-StrictMode -Version Latest $script:ServerAuthOid = '1.3.6.1.5.5.7.3.1' $script:VaTlsConfigUrl = 'https://documentation.sailpoint.com/connectors/iqservice/help/common/va_topics_and_snippets/tls_config_on_va.html' $script:VaConfigTlsUrl = 'https://documentation.sailpoint.com/saas/help/va/config_va.html#transport-layer-security' $script:LdapsPort = 636 $script:FirewallRuleName = 'SailPoint AD LDAPS (TCP 636)' $script:ManagedFriendlyNamePrefix = 'SailPoint AD LDAPS' function Get-AdLdapsMachineDnsFullyQualifiedName { if (-not ('AdLdapsNative' -as [type])) { Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; using System.Text; public static class AdLdapsNative { [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern bool GetComputerNameEx(int nameType, StringBuilder buffer, ref uint size); public static string GetDnsFullyQualifiedName() { const int ComputerNameDnsFullyQualified = 3; uint size = 1024; var buffer = new StringBuilder((int)size); if (!GetComputerNameEx(ComputerNameDnsFullyQualified, buffer, ref size)) { throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); } return buffer.ToString().TrimEnd('.'); } } '@ } try { return [AdLdapsNative]::GetDnsFullyQualifiedName() } catch { if (-not [string]::IsNullOrWhiteSpace($env:USERDNSDOMAIN) -and -not [string]::IsNullOrWhiteSpace($env:COMPUTERNAME)) { return "$($env:COMPUTERNAME).$($env:USERDNSDOMAIN)".TrimEnd('.') } try { return [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName.TrimEnd('.') } catch { return $env:COMPUTERNAME } } } function Get-AdLdapsHostNames { param([string[]]$ExtraDnsName) $names = [System.Collections.Generic.List[string]]::new() $candidates = [System.Collections.Generic.List[string]]::new() # AD DS ValidateLdapCertificate matches GetComputerNameEx(ComputerNameDnsFullyQualified). $adDns = Get-AdLdapsMachineDnsFullyQualifiedName if ($adDns) { $candidates.Add($adDns) } if (-not [string]::IsNullOrWhiteSpace($env:USERDNSDOMAIN) -and -not [string]::IsNullOrWhiteSpace($env:COMPUTERNAME)) { $candidates.Add("$($env:COMPUTERNAME).$($env:USERDNSDOMAIN)") } try { $byName = [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName if ($byName) { $candidates.Add($byName) } } catch { } if ($env:COMPUTERNAME) { $candidates.Add($env:COMPUTERNAME) } foreach ($name in @($candidates) + @($ExtraDnsName)) { if ([string]::IsNullOrWhiteSpace($name)) { continue } $normalized = $name.Trim().TrimEnd('.').ToLowerInvariant() if ($normalized -eq 'localhost') { continue } if ($names -notcontains $normalized) { $names.Add($normalized) } } return [string[]]$names.ToArray() } function Test-AdDomainController { $ntds = Get-Service -Name 'NTDS' -ErrorAction SilentlyContinue if ($ntds) { return [PSCustomObject]@{ IsDomainController = $true Reason = "NTDS service is present (Status=$($ntds.Status))." NtdsStatus = [string]$ntds.Status } } try { $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop # DomainRole: 4 = Backup DC, 5 = Primary DC if ([int]$cs.DomainRole -in @(4, 5)) { return [PSCustomObject]@{ IsDomainController = $true Reason = "Win32_ComputerSystem DomainRole=$($cs.DomainRole)." NtdsStatus = $null } } return [PSCustomObject]@{ IsDomainController = $false Reason = "Host DomainRole=$($cs.DomainRole) is not a domain controller (need 4 or 5)." NtdsStatus = $null } } catch { return [PSCustomObject]@{ IsDomainController = $false Reason = "Unable to determine domain controller role: $($_.Exception.Message)" NtdsStatus = $null } } } function Assert-AdDomainController { $check = Test-AdDomainController if (-not $check.IsDomainController) { throw "EnableLdaps requires a domain controller. $($check.Reason)" } return $check } function Get-AdLdapsCertificateDnsNames { param([Parameter(Mandatory)]$Certificate) $names = [System.Collections.Generic.List[string]]::new() if ($Certificate.PSObject.Properties['DnsNames'] -and $Certificate.DnsNames) { foreach ($n in @($Certificate.DnsNames)) { if ($n) { $names.Add([string]$n) } } return [string[]]$names.ToArray() } $subject = [string]$Certificate.Subject if ($subject -match 'CN=([^,]+)') { $names.Add($Matches[1].Trim()) } if ($Certificate.PSObject.Properties['Extensions'] -and $Certificate.Extensions) { foreach ($ext in @($Certificate.Extensions)) { if (-not $ext.Oid -or $ext.Oid.Value -ne '2.5.29.17') { continue } try { $san = New-Object System.Security.Cryptography.X509Certificates.X509SubjectAlternativeNameExtension($ext.RawData, $false) $formatted = $san.Format($false) foreach ($part in ($formatted -split '[,;\r\n]+')) { $trimmed = $part.Trim() if ($trimmed -match '(?i)^DNS Name[=:]?\s*(.+)$' -or $trimmed -match '(?i)^DNS=(.+)$') { $names.Add($Matches[1].Trim()) } } } catch { } } } return [string[]]@($names | Select-Object -Unique) } function Test-AdLdapsCertificateNameMatch { param( [Parameter(Mandatory)]$Certificate, [Parameter(Mandatory)][string[]]$HostNames ) $certNames = @(Get-AdLdapsCertificateDnsNames -Certificate $Certificate | ForEach-Object { $_.TrimEnd('.').ToLowerInvariant() }) foreach ($hostName in $HostNames) { $want = $hostName.TrimEnd('.').ToLowerInvariant() if ($certNames -contains $want) { return $true } foreach ($cn in $certNames) { if ($cn.StartsWith('*.') -and $want.EndsWith($cn.Substring(1))) { return $true } } } return $false } function Get-AdLdapsKeySpec { param([Parameter(Mandatory)]$Certificate) if ($Certificate.PSObject.Properties['KeySpec'] -and $null -ne $Certificate.KeySpec) { return [string]$Certificate.KeySpec } try { if ($Certificate.HasPrivateKey -and $Certificate.PrivateKey -and $Certificate.PrivateKey.CspKeyContainerInfo) { $keyNumber = [int]$Certificate.PrivateKey.CspKeyContainerInfo.KeyNumber # 1 = Exchange, 2 = Signature if ($keyNumber -eq 1) { return 'KeyExchange' } if ($keyNumber -eq 2) { return 'Signature' } } } catch { } try { $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) if ($rsa) { # CNG keys used with New-SelfSignedCertificate -KeySpec KeyExchange are exchange-capable. return 'KeyExchange' } } catch { } return 'Unknown' } function Get-AdLdapsEnhancedKeyUsages { param([Parameter(Mandatory)]$Certificate) $oids = [System.Collections.Generic.List[string]]::new() foreach ($item in @( $(if ($Certificate.PSObject.Properties['EnhancedKeyUsageList'] -and $null -ne $Certificate.EnhancedKeyUsageList) { @($Certificate.EnhancedKeyUsageList) } else { @() }) )) { if ($null -eq $item) { continue } if ($item -is [System.Security.Cryptography.Oid] -and $item.Value) { $oids.Add([string]$item.Value) continue } if ($item.PSObject.Properties['Value'] -and [string]$item.Value -match '^\d+(\.\d+)+$') { $oids.Add([string]$item.Value) continue } if ($item.PSObject.Properties['ObjectId'] -and $item.ObjectId) { $oids.Add([string]$item.ObjectId) continue } $text = [string]$item if ($text -match '^\d+(\.\d+)+$') { $oids.Add($text) } elseif ($text -match '(?i)Server Authentication') { $oids.Add($script:ServerAuthOid) } } if ($oids.Count -gt 0) { return [string[]]@($oids | Select-Object -Unique) } if (-not $Certificate.PSObject.Properties['Extensions'] -or -not $Certificate.Extensions) { return $null } $ekuExt = @($Certificate.Extensions) | Where-Object { $_.Oid -and $_.Oid.Value -eq '2.5.29.37' } | Select-Object -First 1 if (-not $ekuExt) { return $null } $eku = New-Object System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension($ekuExt, $false) foreach ($item in @($eku.EnhancedKeyUsages)) { if ($item -and $item.Value) { $oids.Add([string]$item.Value) } } if ($oids.Count -eq 0) { return $null } return [string[]]@($oids | Select-Object -Unique) } function Test-AdLdapsCertificateHasPrivateKey { param([Parameter(Mandatory)]$Certificate) # Prefer probing the actual key. HasPrivateKey alone is unreliable after a bad CSP # create or Root-store Add() — it can stay $true while GetRSAPrivateKey returns null. try { $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) if ($rsa) { return $true } } catch { } try { if ($Certificate.PrivateKey) { return $true } } catch { } $isRealCert = $Certificate -is [System.Security.Cryptography.X509Certificates.X509Certificate2] if ($isRealCert) { return $false } # Unit-test mocks expose HasPrivateKey without a real key handle. if ($Certificate.PSObject.Properties['HasPrivateKey'] -and [bool]$Certificate.HasPrivateKey) { return $true } return $false } function Test-AdLdapsManagedCertificate { param([Parameter(Mandatory)]$Certificate) if (-not $Certificate.PSObject.Properties['FriendlyName'] -or -not $Certificate.FriendlyName) { return $false } return ([string]$Certificate.FriendlyName).StartsWith( $script:ManagedFriendlyNamePrefix, [System.StringComparison]::OrdinalIgnoreCase ) } function Get-AdLdapsKeyUsageFlags { param([Parameter(Mandatory)]$Certificate) if ($Certificate.PSObject.Properties['KeyUsageFlags'] -and $null -ne $Certificate.KeyUsageFlags) { return [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]$Certificate.KeyUsageFlags } if (-not $Certificate.PSObject.Properties['Extensions'] -or -not $Certificate.Extensions) { return $null } $kuExt = @($Certificate.Extensions) | Where-Object { $_.Oid -and $_.Oid.Value -eq '2.5.29.15' } | Select-Object -First 1 if (-not $kuExt) { return $null } $ku = New-Object System.Security.Cryptography.X509Certificates.X509KeyUsageExtension($kuExt, $false) return $ku.KeyUsages } function Test-AdLdapsCertificateUses { param([Parameter(Mandatory)]$Certificate) $reasons = [System.Collections.Generic.List[string]]::new() $details = [ordered]@{} $hasPrivateKey = Test-AdLdapsCertificateHasPrivateKey -Certificate $Certificate $details['HasPrivateKey'] = $hasPrivateKey if (-not $hasPrivateKey) { $reasons.Add('Certificate has no associated private key.') } $ekus = Get-AdLdapsEnhancedKeyUsages -Certificate $Certificate $details['EnhancedKeyUsages'] = $ekus if ($null -eq $ekus) { $details['EnhancedKeyUsage'] = 'Absent (treated as all purposes)' } elseif ($ekus -notcontains $script:ServerAuthOid) { $reasons.Add("Enhanced Key Usage does not include Server Authentication ($($script:ServerAuthOid)).") } else { $details['EnhancedKeyUsage'] = 'Server Authentication present' } $ku = Get-AdLdapsKeyUsageFlags -Certificate $Certificate $details['KeyUsageFlags'] = $ku if ($null -eq $ku) { $details['KeyUsage'] = 'Absent (treated as unrestricted)' } else { $needDigital = [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature $needEncipher = [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment $missing = [System.Collections.Generic.List[string]]::new() if (($ku -band $needDigital) -ne $needDigital) { $missing.Add('DigitalSignature') } if (($ku -band $needEncipher) -ne $needEncipher) { $missing.Add('KeyEncipherment') } if ($missing.Count -gt 0) { $reasons.Add("Key Usage is missing required bit(s): $($missing -join ', ').") } else { $details['KeyUsage'] = 'DigitalSignature + KeyEncipherment' } } $keySpec = Get-AdLdapsKeySpec -Certificate $Certificate $details['KeySpec'] = $keySpec if ($keySpec -eq 'Signature') { $reasons.Add('KeySpec is signature-only (AT_SIGNATURE); LDAPS/Schannel requires key exchange (AT_KEYEXCHANGE).') } return [PSCustomObject]@{ Ok = ($reasons.Count -eq 0) Reasons = @($reasons) Details = $details } } function Test-AdLdapsCertificateCandidate { param( [Parameter(Mandatory)]$Certificate, [Parameter(Mandatory)][string[]]$HostNames, [string]$RequiredDnsName, [datetime]$Now = $(Get-Date) ) $reasons = [System.Collections.Generic.List[string]]::new() $notBefore = [datetime]$Certificate.NotBefore $notAfter = [datetime]$Certificate.NotAfter if ($Now -lt $notBefore -or $Now -gt $notAfter) { $reasons.Add("Certificate is outside validity window ($notBefore - $notAfter).") } # AD DS matches GetComputerNameEx(DnsFullyQualified). Short name alone (CN=ad-resource) is not enough. $required = if ($RequiredDnsName) { $RequiredDnsName.TrimEnd('.').ToLowerInvariant() } elseif ($HostNames.Count -gt 0 -and $HostNames[0] -match '\.') { $HostNames[0].TrimEnd('.').ToLowerInvariant() } else { try { (Get-AdLdapsMachineDnsFullyQualifiedName).TrimEnd('.').ToLowerInvariant() } catch { $null } } if ($required) { if (-not (Test-AdLdapsCertificateNameMatch -Certificate $Certificate -HostNames @($required))) { $have = @(Get-AdLdapsCertificateDnsNames -Certificate $Certificate) -join ', ' $reasons.Add("Certificate DNS names ($have) do not include the AD DS FQDN '$required' (short name alone is not enough for LDAPS).") } } elseif (-not (Test-AdLdapsCertificateNameMatch -Certificate $Certificate -HostNames $HostNames)) { $reasons.Add("Certificate DNS names do not match host ($($HostNames -join ', ')).") } $uses = Test-AdLdapsCertificateUses -Certificate $Certificate foreach ($r in $uses.Reasons) { $reasons.Add($r) } return [PSCustomObject]@{ Ok = ($reasons.Count -eq 0) Reasons = @($reasons) Uses = $uses } } function Get-AdLdapsCertificateStoreCandidates { param( [string[]]$StoreNames = @('NTDS', 'My'), [string]$Location = 'LocalMachine' ) $results = [System.Collections.Generic.List[object]]::new() foreach ($storeName in $StoreNames) { $store = $null try { $store = New-Object System.Security.Cryptography.X509Certificates.X509Store($storeName, $Location) $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) } catch { continue } try { foreach ($cert in $store.Certificates) { $results.Add([PSCustomObject]@{ Certificate = $cert StoreName = $storeName Location = $Location }) } } finally { if ($store) { $store.Close() } } } return [object[]]$results.ToArray() } function Find-AdLdapsCertificate { param( [string]$Thumbprint, [string[]]$DnsName, [string]$RequiredDnsName, [object[]]$Candidates, [datetime]$Now = $(Get-Date) ) $hostNames = Get-AdLdapsHostNames -ExtraDnsName $DnsName if (-not $RequiredDnsName) { try { $RequiredDnsName = Get-AdLdapsMachineDnsFullyQualifiedName } catch { } if (-not $RequiredDnsName -and $hostNames.Count -gt 0) { $RequiredDnsName = $hostNames[0] } } if (-not $Candidates) { $Candidates = @(Get-AdLdapsCertificateStoreCandidates) } $rejected = [System.Collections.Generic.List[object]]::new() $accepted = [System.Collections.Generic.List[object]]::new() foreach ($item in $Candidates) { $cert = if ($item.PSObject.Properties['Certificate']) { $item.Certificate } else { $item } $storeName = if ($item.PSObject.Properties['StoreName']) { [string]$item.StoreName } else { 'My' } if ($Thumbprint) { $tp = ($Thumbprint -replace '\s', '').ToUpperInvariant() $certTp = ([string]$cert.Thumbprint -replace '\s', '').ToUpperInvariant() if ($certTp -ne $tp) { continue } } $check = Test-AdLdapsCertificateCandidate -Certificate $cert -HostNames $hostNames ` -RequiredDnsName $RequiredDnsName -Now $Now # Re-open from My so we never offer a public-only / broken-key leftover from a failed create. if ($check.Ok -and $storeName -eq 'My' -and ($cert -is [System.Security.Cryptography.X509Certificates.X509Certificate2])) { try { $cert = Get-AdLdapsCertificateFromLocalMachineMy -Thumbprint $cert.Thumbprint } catch { $check = [PSCustomObject]@{ Ok = $false Reasons = @($_.Exception.Message) Uses = $null } if (Test-AdLdapsManagedCertificate -Certificate $item.Certificate) { $null = Remove-AdLdapsCertificateByThumbprint -Thumbprint ([string]$item.Certificate.Thumbprint) } } } $entry = [PSCustomObject]@{ Certificate = $cert StoreName = $storeName Thumbprint = [string]$cert.Thumbprint NotAfter = [datetime]$cert.NotAfter DnsNames = @(Get-AdLdapsCertificateDnsNames -Certificate $cert) Check = $check } if ($check.Ok) { $accepted.Add($entry) } else { $rejected.Add($entry) foreach ($reason in $check.Reasons) { if (Get-Command Write-Info -ErrorAction SilentlyContinue) { Write-Info "Skipping $($cert.Thumbprint) ($storeName): $reason" } } } } if ($Thumbprint -and $accepted.Count -eq 0) { $pinRejects = @($rejected | Where-Object { ([string]$_.Thumbprint -replace '\s', '').ToUpperInvariant() -eq ($Thumbprint -replace '\s', '').ToUpperInvariant() }) if ($pinRejects.Count -gt 0) { $why = ($pinRejects[0].Check.Reasons) -join ' ' throw "Certificate thumbprint '$Thumbprint' does not have the required LDAPS uses: $why" } throw "Certificate thumbprint '$Thumbprint' was not found in LocalMachine NTDS or My stores." } $ranked = @($accepted | Sort-Object ` @{ Expression = { if ($_.StoreName -eq 'NTDS') { 0 } else { 1 } }; Ascending = $true }, ` @{ Expression = { $_.NotAfter }; Ascending = $false }, ` @{ Expression = { $names = @($_.DnsNames | ForEach-Object { $_.ToLowerInvariant() }) if ($names -contains $hostNames[0]) { 0 } else { 1 } }; Ascending = $true }) return [PSCustomObject]@{ Certificate = if ($ranked.Count -gt 0) { $ranked[0].Certificate } else { $null } StoreName = if ($ranked.Count -gt 0) { $ranked[0].StoreName } else { $null } Thumbprint = if ($ranked.Count -gt 0) { $ranked[0].Thumbprint } else { $null } HostNames = $hostNames Accepted = $ranked Rejected = @($rejected) } } function Get-AdLdapsNtdsServiceCertificateRegistryPath { return 'HKLM:\Software\Microsoft\Cryptography\Services\NTDS\SystemCertificates\My\Certificates' } function Get-AdLdapsLocalMachineCertificateRegistryPath { param([Parameter(Mandatory)][string]$Thumbprint) $tp = ($Thumbprint -replace '\s', '').ToUpperInvariant() return "HKLM:\Software\Microsoft\SystemCertificates\My\Certificates\$tp" } function Test-AdLdapsCertificateInNtdsServiceStore { param([Parameter(Mandatory)][string]$Thumbprint) $tp = ($Thumbprint -replace '\s', '').ToUpperInvariant() $path = Join-Path (Get-AdLdapsNtdsServiceCertificateRegistryPath) $tp return Test-Path -LiteralPath $path } function Initialize-AdLdapsCertOpenStoreType { if ('AdLdapsCertStoreNative' -as [type]) { return } Add-Type -TypeDefinition @' using Microsoft.Win32.SafeHandles; using System; using System.Runtime.InteropServices; namespace AdLdapsCertStore { public class NativeMethods { [DllImport("Crypt32.dll")] public static extern bool CertCloseStore(IntPtr hCertStore, uint dwFlags); [DllImport("Crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true)] public static extern SafeX509Store CertOpenStore( IntPtr lpszStoreProvider, uint dwEncodingType, IntPtr hCryptProv, uint dwFlags, string pvPara); } public class SafeX509Store : SafeHandleZeroOrMinusOneIsInvalid { public SafeX509Store() : base(true) { } protected override bool ReleaseHandle() { return NativeMethods.CertCloseStore(handle, 0); } } } '@ } function Open-AdLdapsServiceCertificateStore { param( [string]$ServiceName = 'NTDS', [string]$StoreName = 'My', [System.Security.Cryptography.X509Certificates.OpenFlags]$OpenFlags = [System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite ) Initialize-AdLdapsCertOpenStoreType # CERT_STORE_PROV_SYSTEM_W = 10, CERT_SYSTEM_STORE_SERVICES = 0x00050000 $provider = [IntPtr]::new(10) $flags = [uint32](0x00050000 -bor 0x00000004) $openMode = [int]$OpenFlags -band 3 switch ($openMode) { 0 { $flags = $flags -bor 0x00008000 } # ReadOnly -> CERT_STORE_READONLY_FLAG 2 { $flags = $flags -bor 0x00001000 } # MaxAllowed / OpenExisting-ish write path } if ($OpenFlags.HasFlag([System.Security.Cryptography.X509Certificates.OpenFlags]::OpenExistingOnly)) { $flags = $flags -bor 0x00004000 } $handle = [AdLdapsCertStore.NativeMethods]::CertOpenStore( $provider, 0, [IntPtr]::Zero, $flags, "$ServiceName\$StoreName" ) $err = [Runtime.InteropServices.Marshal]::GetLastWin32Error() if ($handle.IsInvalid) { throw "Failed to open service certificate store '$ServiceName\$StoreName': $(([ComponentModel.Win32Exception]$err).Message)" } try { # Transfer ownership of the store handle to X509Store; do not CertCloseStore via SafeHandle. $ptr = $handle.DangerousGetHandle() $store = New-Object System.Security.Cryptography.X509Certificates.X509Store($ptr) $handle.SetHandleAsInvalid() return $store } catch { $handle.Dispose() throw } } function Get-AdLdapsCertificateFromLocalMachineMy { param([Parameter(Mandatory)][string]$Thumbprint) $tp = ($Thumbprint -replace '\s', '').ToUpperInvariant() $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('My', 'LocalMachine') $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) try { $cert = $store.Certificates | Where-Object { ([string]$_.Thumbprint -replace '\s', '').ToUpperInvariant() -eq $tp } | Select-Object -First 1 if (-not $cert) { throw "Certificate $tp was not found in LocalMachine\My." } if (-not (Test-AdLdapsCertificateHasPrivateKey -Certificate $cert)) { throw "Certificate $tp in LocalMachine\My has no private key. AD DS cannot use it for LDAPS." } return $cert } finally { $store.Close() } } function Test-AdLdapsCertificateIsSelfSigned { param([Parameter(Mandatory)]$Certificate) try { $subject = [string]$Certificate.Subject $issuer = [string]$Certificate.Issuer if ($subject -and $issuer -and ($subject -eq $issuer)) { return $true } } catch { } return $false } function Remove-AdLdapsCertificateByThumbprint { param( [Parameter(Mandatory)][string]$Thumbprint, [string[]]$StoreNames = @('My', 'Root') ) $tp = ($Thumbprint -replace '\s', '').ToUpperInvariant() $removed = [System.Collections.Generic.List[string]]::new() foreach ($storeName in $StoreNames) { $store = $null try { $store = New-Object System.Security.Cryptography.X509Certificates.X509Store($storeName, 'LocalMachine') $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) $matches = @($store.Certificates | Where-Object { ([string]$_.Thumbprint -replace '\s', '').ToUpperInvariant() -eq $tp }) foreach ($cert in $matches) { $store.Remove($cert) $removed.Add("$storeName") } } catch { } finally { if ($store) { $store.Close() } } } try { $ntds = Open-AdLdapsServiceCertificateStore -ServiceName 'NTDS' -StoreName 'My' ` -OpenFlags ([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) try { $matches = @($ntds.Certificates | Where-Object { ([string]$_.Thumbprint -replace '\s', '').ToUpperInvariant() -eq $tp }) foreach ($cert in $matches) { $ntds.Remove($cert) $removed.Add('NTDS\My') } } finally { $ntds.Close() } } catch { } $regPath = Join-Path (Get-AdLdapsNtdsServiceCertificateRegistryPath) $tp if (Test-Path -LiteralPath $regPath) { Remove-Item -LiteralPath $regPath -Recurse -Force -ErrorAction SilentlyContinue $removed.Add('NTDS-registry') } return [string[]]@($removed | Select-Object -Unique) } function Clear-AdLdapsUnusableManagedCertificates { $removed = [System.Collections.Generic.List[string]]::new() $candidates = @(Get-AdLdapsCertificateStoreCandidates -StoreNames @('My')) foreach ($item in $candidates) { $cert = $item.Certificate if (-not (Test-AdLdapsManagedCertificate -Certificate $cert)) { continue } $uses = Test-AdLdapsCertificateUses -Certificate $cert if ($uses.Ok) { continue } $tp = ([string]$cert.Thumbprint -replace '\s', '').ToUpperInvariant() $why = ($uses.Reasons | Select-Object -First 1) if (Get-Command Write-Info -ErrorAction SilentlyContinue) { Write-Info "Removing unusable managed LDAPS certificate $tp ($why)" } $null = Remove-AdLdapsCertificateByThumbprint -Thumbprint $tp $removed.Add($tp) } return [string[]]@($removed) } function Clear-AdLdapsManagedCertificates { param([string]$KeepThumbprint) $keep = if ($KeepThumbprint) { ($KeepThumbprint -replace '\s', '').ToUpperInvariant() } else { $null } $removed = [System.Collections.Generic.List[string]]::new() $candidates = @(Get-AdLdapsCertificateStoreCandidates -StoreNames @('My')) foreach ($item in $candidates) { $cert = $item.Certificate if (-not (Test-AdLdapsManagedCertificate -Certificate $cert)) { continue } $tp = ([string]$cert.Thumbprint -replace '\s', '').ToUpperInvariant() if ($keep -and $tp -eq $keep) { continue } if (Get-Command Write-Info -ErrorAction SilentlyContinue) { Write-Info "Removing previous managed LDAPS certificate $tp ($($cert.FriendlyName))" } $null = Remove-AdLdapsCertificateByThumbprint -Thumbprint $tp $removed.Add($tp) } return [string[]]@($removed) } function Install-AdLdapsCertificateIntoTrustedRoot { param([Parameter(Mandatory)]$Certificate) $tp = ([string]$Certificate.Thumbprint -replace '\s', '').ToUpperInvariant() $rootStore = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'LocalMachine') $rootStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) try { $existing = $rootStore.Certificates | Where-Object { ([string]$_.Thumbprint -replace '\s', '').ToUpperInvariant() -eq $tp } | Select-Object -First 1 if ($existing) { Write-Info "Certificate $tp is already in LocalMachine\Root." return $false } # Public-only copy — never push the private-key handle into the Root store. $publicBytes = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) $publicCert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$publicBytes) $rootStore.Add($publicCert) Write-Ok "Added certificate $tp to LocalMachine\Root (required for AD DS to trust a self-signed LDAPS cert)." return $true } finally { $rootStore.Close() } } function Test-AdLdapsCertificateSchannelReady { param( [Parameter(Mandatory)]$Certificate, [string]$DnsName ) $reasons = [System.Collections.Generic.List[string]]::new() $expected = if ($DnsName) { $DnsName.TrimEnd('.').ToLowerInvariant() } else { (Get-AdLdapsMachineDnsFullyQualifiedName).TrimEnd('.').ToLowerInvariant() } $certNames = @(Get-AdLdapsCertificateDnsNames -Certificate $Certificate | ForEach-Object { $_.TrimEnd('.').ToLowerInvariant() }) if ($certNames -notcontains $expected) { $reasons.Add("Certificate DNS names ($($certNames -join ', ')) do not include the AD DS FQDN '$expected'. Short name alone (e.g. ad-resource) will not enable LDAPS — create a new cert or pick one that includes the FQDN.") } $uses = Test-AdLdapsCertificateUses -Certificate $Certificate foreach ($r in $uses.Reasons) { $reasons.Add($r) } if ((Test-AdLdapsCertificateIsSelfSigned -Certificate $Certificate)) { Install-AdLdapsCertificateIntoTrustedRoot -Certificate $Certificate | Out-Null } $testCert = Get-Command Test-Certificate -ErrorAction SilentlyContinue if ($testCert -and $reasons.Count -eq 0) { try { $ok = Test-Certificate -Cert $Certificate -Policy SSL -EKU @($script:ServerAuthOid) -DNSName $expected -ErrorAction Stop if (-not $ok) { $reasons.Add('Test-Certificate -Policy SSL failed for this certificate (chain/EKU/DNS).') } } catch { $msg = [string]$_.Exception.Message # Self-signed / lab CAs: AD DS still selects certs with unknown revocation; untrusted root # after we just installed to Root can be a timing/cache issue — warn, do not hard-fail. if ($msg -match '(?i)revocation|UNTRUSTEDROOT|not trusted by the trust provider|0x800b0109') { if (Get-Command Write-Info -ErrorAction SilentlyContinue) { Write-Info "Test-Certificate trust/revocation warning (continuing for LDAPS): $msg" } } else { $reasons.Add("Test-Certificate -Policy SSL: $msg") } } } return [PSCustomObject]@{ Ok = ($reasons.Count -eq 0) Reasons = @($reasons) ExpectedDns = $expected CertificateDns = $certNames } } function Install-AdLdapsCertificateIntoNtdsServiceStore { param( [Parameter(Mandatory)][string]$Thumbprint, [switch]$RemoveCompetingCertificates ) $tp = ($Thumbprint -replace '\s', '').ToUpperInvariant() $cert = Get-AdLdapsCertificateFromLocalMachineMy -Thumbprint $tp # Prefer CertOpenStore("NTDS\My") + Add() so the private key is available to the NTDS # service. A bare registry Copy-Item of the LocalMachine\My blob often leaves a public # cert without a usable key, and AD DS silently skips it (no listener on 636). $store = $null $added = $false $removed = [System.Collections.Generic.List[string]]::new() try { $store = Open-AdLdapsServiceCertificateStore -ServiceName 'NTDS' -StoreName 'My' ` -OpenFlags ([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) # RabbitMQ / FAM / short-name certs in NTDS\My win selection or break TLS even when # TCP 636 is open. Keep only the LDAPS cert we intend AD DS to present. if ($RemoveCompetingCertificates) { $competitors = @($store.Certificates | Where-Object { ([string]$_.Thumbprint -replace '\s', '').ToUpperInvariant() -ne $tp }) foreach ($other in $competitors) { $otherTp = ([string]$other.Thumbprint -replace '\s', '').ToUpperInvariant() $label = if ($other.Subject) { $other.Subject } else { $otherTp } Write-Info "Removing competing NTDS\My certificate: $label ($otherTp)" $store.Remove($other) $removed.Add($otherTp) } } $existing = @($store.Certificates | Where-Object { ([string]$_.Thumbprint -replace '\s', '').ToUpperInvariant() -eq $tp }) foreach ($old in $existing) { if (-not $old.HasPrivateKey) { Write-Info "Removing NTDS\My entry for $tp that has no private key (likely a registry-only copy)." $store.Remove($old) } } $stillThere = @($store.Certificates | Where-Object { ([string]$_.Thumbprint -replace '\s', '').ToUpperInvariant() -eq $tp -and $_.HasPrivateKey }) if ($stillThere.Count -eq 0) { $store.Add($cert) $added = $true Write-Ok "Added certificate $tp with private key to the NTDS service Personal store (NTDS\My)." } else { Write-Info "Certificate $tp with private key is already in NTDS\My." } } catch { Write-Info "CertOpenStore NTDS\My failed ($($_.Exception.Message)); falling back to registry link." $destRoot = Get-AdLdapsNtdsServiceCertificateRegistryPath if (-not (Test-Path -LiteralPath $destRoot)) { New-Item -Path $destRoot -Force | Out-Null } if ($RemoveCompetingCertificates -and (Test-Path -LiteralPath $destRoot)) { Get-ChildItem -LiteralPath $destRoot -ErrorAction SilentlyContinue | ForEach-Object { if ($_.PSChildName.ToUpperInvariant() -ne $tp) { Write-Info "Removing competing NTDS registry cert $($_.PSChildName)" Remove-Item -LiteralPath $_.PSPath -Recurse -Force -ErrorAction SilentlyContinue $removed.Add($_.PSChildName.ToUpperInvariant()) } } } $source = Get-AdLdapsLocalMachineCertificateRegistryPath -Thumbprint $tp $dest = Join-Path $destRoot $tp if (-not (Test-Path -LiteralPath $dest)) { Copy-Item -Path $source -Destination $destRoot -ErrorAction Stop $added = $true Write-Ok "Linked certificate $tp into NTDS service store via registry (fallback)." } } finally { if ($store) { $store.Close() } } return [PSCustomObject]@{ Added = $added RemovedThumbprints = @($removed) } } function Invoke-AdLdapsCertificateRenewal { try { $dse = [adsi]'LDAP://localhost/rootDSE' [void]$dse.Properties['renewServerCertificate'].Add(1) $dse.CommitChanges() Write-Ok 'Triggered RootDSE renewServerCertificate so AD DS reloads the LDAPS certificate.' return $true } catch { Write-Info "Could not trigger renewServerCertificate ($($_.Exception.Message)). NTDS restart may still be required." return $false } } function New-AdLdapsSelfSignedCertificate { param( [string[]]$DnsName, [datetime]$NotAfter = $(Get-Date).AddYears(2) ) $hostNames = Get-AdLdapsHostNames -ExtraDnsName $DnsName $fqdn = $null try { $fqdn = Get-AdLdapsMachineDnsFullyQualifiedName } catch { } if ($fqdn) { $fqdn = $fqdn.TrimEnd('.').ToLowerInvariant() # Ensure FQDN is first (Subject CN) — AD DS will not select a short-name-only cert. $hostNames = @($fqdn) + @($hostNames | Where-Object { $_.TrimEnd('.').ToLowerInvariant() -ne $fqdn }) } if ($hostNames.Count -eq 0) { throw 'Unable to determine DNS names for the LDAPS certificate.' } $friendly = "SailPoint AD LDAPS ($($hostNames[0]))" # Prefer the default CNG/Schannel-capable provider. The legacy "Microsoft RSA SChannel # Cryptographic Provider" often yields a cert whose private key is not visible to the # creating process (HasPrivateKey=$false), which then fails Schannel validation. $params = @{ Subject = "CN=$($hostNames[0])" DnsName = $hostNames KeyAlgorithm = 'RSA' KeyLength = 2048 KeyExportPolicy = 'Exportable' KeySpec = 'KeyExchange' KeyUsage = @('DigitalSignature', 'KeyEncipherment') TextExtension = @("2.5.29.37={text}$($script:ServerAuthOid)") NotAfter = $NotAfter CertStoreLocation = 'Cert:\LocalMachine\My' FriendlyName = $friendly } # Replace prior SailPoint-created LDAPS certs so a broken create does not linger in the picker. $null = Clear-AdLdapsManagedCertificates Write-Step "Creating self-signed LDAPS certificate for $($hostNames -join ', ')" Write-Info "Subject CN / primary SAN will be '$($hostNames[0])' (must match AD DS FQDN)." $created = New-SelfSignedCertificate @params $thumb = [string]$created.Thumbprint # Re-open from LocalMachine\My so we hold a cert object with a usable private key after # any subsequent public-only Root store import. $cert = Get-AdLdapsCertificateFromLocalMachineMy -Thumbprint $thumb Write-Ok "Created certificate $thumb (CN/SAN: $($hostNames -join ', '))" $uses = Test-AdLdapsCertificateUses -Certificate $cert if (-not $uses.Ok) { throw "Newly created certificate $thumb failed LDAPS uses checks: $($uses.Reasons -join ' ')" } Install-AdLdapsCertificateIntoTrustedRoot -Certificate $cert | Out-Null # Refresh again after Root import. $cert = Get-AdLdapsCertificateFromLocalMachineMy -Thumbprint $thumb return $cert } function Enable-AdLdapsFirewallRule { $existing = Get-NetFirewallRule -DisplayName $script:FirewallRuleName -ErrorAction SilentlyContinue if ($existing) { Write-Info "Firewall rule already exists: $($script:FirewallRuleName)" return $false } New-NetFirewallRule -DisplayName $script:FirewallRuleName ` -Direction Inbound ` -Protocol TCP ` -LocalPort $script:LdapsPort ` -Action Allow ` -Profile Any ` -Description 'Allow LDAPS for SailPoint ISC Active Directory / VA TLS' | Out-Null Write-Ok "Opened inbound TCP $($script:LdapsPort) ($($script:FirewallRuleName))" return $true } function Test-AdLdapsPort { param( [string]$ComputerName = '127.0.0.1', [int]$Port = 636, [int]$TimeoutMs = 2000 ) $listenDetail = $null $getConn = Get-Command Get-NetTCPConnection -ErrorAction SilentlyContinue if ($getConn) { try { $listeners = @(Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) if ($listeners.Count -gt 0) { $addrs = ($listeners | ForEach-Object { "$($_.LocalAddress):$($_.LocalPort)" } | Select-Object -Unique) -join ', ' return [PSCustomObject]@{ Listening = $true Detail = "TCP $Port is listening ($addrs)" } } $listenDetail = "Get-NetTCPConnection found no Listen state on TCP $Port" } catch { $listenDetail = "Get-NetTCPConnection failed: $($_.Exception.Message)" } } $client = $null try { $client = New-Object System.Net.Sockets.TcpClient $async = $client.BeginConnect($ComputerName, $Port, $null, $null) $waited = $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false) if (-not $waited) { $extra = if ($listenDetail) { " ($listenDetail)" } else { '' } return [PSCustomObject]@{ Listening = $false; Detail = "Timed out connecting to ${ComputerName}:$Port$extra" } } $client.EndConnect($async) return [PSCustomObject]@{ Listening = $true; Detail = "TCP $Port accepts connections on $ComputerName" } } catch { $extra = if ($listenDetail) { " ($listenDetail)" } else { '' } return [PSCustomObject]@{ Listening = $false; Detail = "$($_.Exception.Message)$extra" } } finally { if ($client) { $client.Dispose() } } } function Test-AdLdapsTlsHandshake { param( [string]$HostName, [int]$Port = 636, [string]$ExpectedThumbprint, [int]$TimeoutMs = 5000 ) if (-not $HostName) { $HostName = Get-AdLdapsMachineDnsFullyQualifiedName } $HostName = $HostName.TrimEnd('.') $tcp = $null $ssl = $null $state = @{ Cert = $null } try { $tcp = New-Object System.Net.Sockets.TcpClient $async = $tcp.BeginConnect($HostName, $Port, $null, $null) if (-not $async.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) { return [PSCustomObject]@{ Ok = $false Detail = "TLS probe timed out connecting to ${HostName}:$Port" Thumbprint = $null Subject = $null } } $tcp.EndConnect($async) $ssl = New-Object System.Net.Security.SslStream($tcp.GetStream(), $false, { param($sender, $certificate, $chain, $errors) if ($certificate) { $state.Cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2($certificate) } return $true }.GetNewClosure()) $ssl.AuthenticateAsClient($HostName) $cert = $state.Cert if (-not $cert) { return [PSCustomObject]@{ Ok = $false Detail = "TLS connected to ${HostName}:$Port but no server certificate was presented." Thumbprint = $null Subject = $null } } $tp = ([string]$cert.Thumbprint -replace '\s', '').ToUpperInvariant() $expected = if ($ExpectedThumbprint) { ($ExpectedThumbprint -replace '\s', '').ToUpperInvariant() } else { $null } if ($expected -and $tp -ne $expected) { return [PSCustomObject]@{ Ok = $false Detail = "TLS on ${HostName}:$Port presented unexpected cert $tp ($($cert.Subject)); expected $expected. Competing NTDS\My certificates may still be selected." Thumbprint = $tp Subject = [string]$cert.Subject } } return [PSCustomObject]@{ Ok = $true Detail = "TLS handshake to ${HostName}:$Port succeeded; presented $tp ($($cert.Subject))" Thumbprint = $tp Subject = [string]$cert.Subject } } catch { return [PSCustomObject]@{ Ok = $false Detail = "TLS handshake to ${HostName}:$Port failed: $($_.Exception.Message)" Thumbprint = $null Subject = $null } } finally { if ($ssl) { $ssl.Dispose() } if ($tcp) { $tcp.Dispose() } } } function Restart-AdNtdsService { Write-Step 'Restarting NTDS so Schannel can pick up the LDAPS certificate' Restart-Service -Name 'NTDS' -Force -ErrorAction Stop $svc = Get-Service -Name 'NTDS' Write-Ok "NTDS status: $($svc.Status)" return [string]$svc.Status } function ConvertTo-X509Pem { param( [Parameter(Mandatory)] [AllowNull()] $Certificate ) if ($null -eq $Certificate) { throw 'Certificate is required for PEM conversion.' } $raw = $null if ($Certificate -is [byte[]]) { $raw = $Certificate } elseif ($null -ne $Certificate.PSObject.Properties['RawData'] -and $null -ne $Certificate.RawData) { $raw = [byte[]]$Certificate.RawData } elseif ($Certificate -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) { $raw = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) } else { throw 'Certificate must provide RawData bytes or be an X509Certificate2.' } $b64 = [Convert]::ToBase64String($raw) $lines = [System.Collections.Generic.List[string]]::new() $lines.Add('-----BEGIN CERTIFICATE-----') for ($i = 0; $i -lt $b64.Length; $i += 64) { $len = [Math]::Min(64, $b64.Length - $i) $lines.Add($b64.Substring($i, $len)) } $lines.Add('-----END CERTIFICATE-----') return ($lines -join "`n") } function Get-AdLdapsCertificateChain { param([Parameter(Mandatory)]$Certificate) if ($null -ne $Certificate.PSObject.Properties['ChainCertificates'] -and $null -ne $Certificate.ChainCertificates) { return [object[]]@($Certificate.ChainCertificates) } $chain = New-Object System.Security.Cryptography.X509Certificates.X509Chain $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck try { [void]$chain.Build($Certificate) } catch { return [object[]]@($Certificate) } $certs = [System.Collections.Generic.List[object]]::new() foreach ($element in $chain.ChainElements) { $certs.Add($element.Certificate) } if ($certs.Count -eq 0) { $certs.Add($Certificate) } return [object[]]$certs.ToArray() } function Export-AdLdapsCertificateChain { param( [Parameter(Mandatory)]$Certificate, [Parameter(Mandatory)][string]$OutputPath, [string]$FilePrefix ) if (-not (Test-Path -LiteralPath $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $hostNames = @(Get-AdLdapsCertificateDnsNames -Certificate $Certificate) $safeHost = if ($hostNames.Count -gt 0) { ($hostNames[0] -replace '[^A-Za-z0-9._-]', '_').ToLowerInvariant() } else { 'ldaps' } if (-not $FilePrefix) { $FilePrefix = "$safeHost-ldaps" } $chain = [System.Collections.Generic.List[object]]::new() foreach ($member in (Get-AdLdapsCertificateChain -Certificate $Certificate)) { $chain.Add($member) } if ($chain.Count -eq 0) { $chain.Add($Certificate) } $written = [System.Collections.Generic.List[string]]::new() $pemBlocks = [System.Collections.Generic.List[string]]::new() for ($i = 0; $i -lt $chain.Count; $i++) { $role = if ($i -eq 0) { 'leaf' } elseif ($i -eq ($chain.Count - 1)) { 'root' } else { 'intermediate' } $fileName = '{0}-{1:D2}-{2}.pem' -f $FilePrefix, $i, $role $path = Join-Path $OutputPath $fileName $pem = ConvertTo-X509Pem -Certificate $chain[$i] Set-Content -LiteralPath $path -Value $pem -Encoding ascii $written.Add($path) $pemBlocks.Add($pem) } $chainPath = Join-Path $OutputPath ("{0}-chain.pem" -f $FilePrefix) Set-Content -LiteralPath $chainPath -Value ($pemBlocks -join "`n`n") -Encoding ascii $written.Add($chainPath) return [PSCustomObject]@{ OutputPath = $OutputPath ChainCount = $chain.Count LeafPath = $written[0] ChainPath = $chainPath Files = @($written) Thumbprint = [string]$Certificate.Thumbprint DnsNames = $hostNames } } function Get-AdLdapsVaManualSteps { param( [string]$ChainPath, [string]$PemDirectory ) $steps = [System.Collections.Generic.List[string]]::new() $steps.Add('Manual VA truststore install (do this on every VA in the cluster before relying on TLS):') $steps.Add('1. Pause connection testing and scheduled aggregations for the Active Directory source if it is already connected.') $steps.Add('2. Confirm the source Hostname matches the certificate SAN/CN (hostname, not an IP address) and that the Domain/Forest port is 636 with Use TLS enabled.') $steps.Add("3. Copy the PEM files (leaf, intermediates, and root — or the concatenated chain) to /home/sailpoint/certificates on each VA. PEM format only.") if ($PemDirectory) { $steps.Add(" Local PEM directory on this host: $PemDirectory") } if ($ChainPath) { $steps.Add(" Concatenated chain file: $ChainPath") } $steps.Add('4. On each VA run: sudo systemctl restart ccg') $steps.Add('5. Watch /home/sailpoint/log/ccg-start.log for "Importing cert" messages. An error usually means the file is not valid PEM.') $steps.Add("6. Tenant toggle reference: $($script:VaConfigTlsUrl)") $steps.Add("7. Manual certificate upload reference: $($script:VaTlsConfigUrl)") return [string[]]$steps.ToArray() } function Show-AdLdapsCompletion { param([Parameter(Mandatory)]$Result) $situation = [System.Collections.Generic.List[string]]::new() if ($Result.CreatedCertificate) { $situation.Add("Created a self-signed LDAPS certificate ($($Result.Thumbprint)) with Server Authentication, DigitalSignature, KeyEncipherment, and KeyExchange.") } else { $situation.Add("Reused existing LDAPS-capable certificate ($($Result.Thumbprint)) from LocalMachine\$($Result.StoreName).") } if ($Result.PSObject.Properties['NtdsServiceLinked'] -and $Result.NtdsServiceLinked) { $situation.Add('Certificate is linked in the NTDS service Personal store (Cryptography\\Services\\NTDS\\...\\My).') } else { $situation.Add('Pending: certificate is not in the NTDS service Personal store — AD DS will not bind TCP 636 until it is.') } if ($Result.PSObject.Properties['RenewServerCertificate'] -and $Result.RenewServerCertificate) { $situation.Add('Triggered RootDSE renewServerCertificate to reload SSL certificates.') } if ($Result.FirewallRuleCreated) { $situation.Add("Opened inbound TCP $($script:LdapsPort) in Windows Firewall.") } if ($Result.NtdsRestarted) { $situation.Add("NTDS was restarted (status: $($Result.NtdsStatus)).") } elseif ($Result.CreatedCertificate -or ($Result.PSObject.Properties['NtdsLinkCreated'] -and $Result.NtdsLinkCreated)) { $situation.Add('Pending: restart NTDS (or reboot) if TCP 636 is not listening yet.') } if ($Result.PSObject.Properties['CompetingRemoved'] -and @($Result.CompetingRemoved).Count -gt 0) { $situation.Add("Removed $(@($Result.CompetingRemoved).Count) competing certificate(s) from NTDS\My so AD DS presents this LDAPS cert.") } if ($Result.PortCheck) { if ($Result.PortCheck.Listening) { $situation.Add("Port $($script:LdapsPort): $($Result.PortCheck.Detail)") } else { $situation.Add("Port $($script:LdapsPort) not accepting connections yet: $($Result.PortCheck.Detail)") } } if ($Result.PSObject.Properties['TlsCheck'] -and $Result.TlsCheck) { if ($Result.TlsCheck.Ok) { $situation.Add("TLS: $($Result.TlsCheck.Detail)") } else { $situation.Add("TLS handshake failed: $($Result.TlsCheck.Detail)") $situation.Add('TCP 636 can be open while the wrong cert is presented — check certutil -store -service NTDS My.') } } $situation.Add("Exported $($Result.Export.ChainCount) certificate(s) as PEM under $($Result.Export.OutputPath).") foreach ($step in @(Get-AdLdapsVaManualSteps -ChainPath $Result.Export.ChainPath -PemDirectory $Result.Export.OutputPath)) { $situation.Add($step) } $items = [System.Collections.Generic.List[object]]::new() $items.Add([PSCustomObject]@{ Label = 'Certificate thumbprint'; Value = $Result.Thumbprint; Kind = 'Copy'; Mask = $false }) if ($Result.DnsNames) { $items.Add([PSCustomObject]@{ Label = 'Certificate DNS names'; Value = ($Result.DnsNames -join ', '); Kind = 'Copy'; Mask = $false }) } $items.Add([PSCustomObject]@{ Label = 'PEM directory'; Value = $Result.Export.OutputPath; Kind = 'Copy'; Mask = $false }) $items.Add([PSCustomObject]@{ Label = 'PEM chain file'; Value = $Result.Export.ChainPath; Kind = 'Copy'; Mask = $false }) $items.Add([PSCustomObject]@{ Label = 'LDAPS port'; Value = [string]$script:LdapsPort; Kind = 'Copy'; Mask = $false }) $items.Add([PSCustomObject]@{ Label = 'VA TLS config (tenant)'; Value = $script:VaConfigTlsUrl; Kind = 'Copy'; Mask = $false }) $items.Add([PSCustomObject]@{ Label = 'VA manual cert upload'; Value = $script:VaTlsConfigUrl; Kind = 'Copy'; Mask = $false }) Write-CompletionSummary -Title 'Next: import LDAPS chain on each VA' ` -Situation @($situation) ` -Items $items.ToArray() } function Format-AdLdapsCertificateChoiceLabel { param( [Parameter(Mandatory)]$Entry, [switch]$IncludeThumbprint ) $store = if ($Entry.PSObject.Properties['StoreName'] -and $Entry.StoreName) { [string]$Entry.StoreName } else { 'My' } $friendly = $null if ($Entry.PSObject.Properties['Certificate'] -and $Entry.Certificate -and $Entry.Certificate.PSObject.Properties['FriendlyName'] -and $Entry.Certificate.FriendlyName) { $friendly = [string]$Entry.Certificate.FriendlyName } $dns = @() if ($Entry.PSObject.Properties['DnsNames'] -and $Entry.DnsNames) { $dns = @($Entry.DnsNames) } elseif ($Entry.PSObject.Properties['Certificate'] -and $Entry.Certificate) { $dns = @(Get-AdLdapsCertificateDnsNames -Certificate $Entry.Certificate) } $dnsText = if ($dns.Count -gt 0) { ($dns | Select-Object -First 2) -join ', ' } else { '(no DNS)' } if ($dns.Count -gt 2) { $dnsText += ', …' } $expires = $null if ($Entry.PSObject.Properties['NotAfter'] -and $Entry.NotAfter) { $expires = ([datetime]$Entry.NotAfter).ToString('yyyy-MM-dd') } elseif ($Entry.PSObject.Properties['Certificate'] -and $Entry.Certificate -and $Entry.Certificate.NotAfter) { $expires = ([datetime]$Entry.Certificate.NotAfter).ToString('yyyy-MM-dd') } $expiryText = if ($expires) { "expires $expires" } else { 'expiry unknown' } $subject = if ($friendly) { $friendly } else { $dnsText } $label = "$store | $subject | $expiryText" if ($friendly -and $dnsText -and $friendly -ne $dnsText) { $label = "$store | $friendly | $dnsText | $expiryText" } if ($IncludeThumbprint) { $tp = if ($Entry.PSObject.Properties['Thumbprint']) { [string]$Entry.Thumbprint } else { [string]$Entry.Certificate.Thumbprint } $short = if ($tp.Length -gt 12) { $tp.Substring(0, 12) + '…' } else { $tp } $label = "$label | $short" } return $label } function Select-AdLdapsCertificate { param( [string[]]$DnsName, [string]$RequiredDnsName, [object[]]$Candidates, [datetime]$Now = $(Get-Date), [string]$Prompt = 'Select the LDAPS certificate to use:' ) if (-not $RequiredDnsName) { try { $RequiredDnsName = Get-AdLdapsMachineDnsFullyQualifiedName } catch { } } # Drop prior SailPoint self-signed LDAPS certs that have no usable private key / uses. if (-not $Candidates) { $null = Clear-AdLdapsUnusableManagedCertificates } $found = Find-AdLdapsCertificate -DnsName $DnsName -RequiredDnsName $RequiredDnsName ` -Candidates $Candidates -Now $Now $accepted = @($found.Accepted) $rejected = @($found.Rejected) if ($RequiredDnsName -and (Get-Command Write-Info -ErrorAction SilentlyContinue)) { Write-Info "AD DS requires certificate SAN/CN to include: $RequiredDnsName" } if ($rejected.Count -gt 0 -and (Get-Command Write-Info -ErrorAction SilentlyContinue)) { Write-Info "$($rejected.Count) certificate(s) in LocalMachine NTDS/My were skipped (failed FQDN match or required uses)." } $options = [System.Collections.Generic.List[string]]::new() $labels = [System.Collections.Generic.List[string]]::new() foreach ($entry in $accepted) { $options.Add([string]$entry.Thumbprint) $labels.Add((Format-AdLdapsCertificateChoiceLabel -Entry $entry -IncludeThumbprint)) } $options.Add('__CREATE__') $labels.Add('Create a new self-signed Schannel certificate') $default = if ($accepted.Count -gt 0) { [string]$accepted[0].Thumbprint } else { '__CREATE__' } if ($accepted.Count -eq 0 -and (Get-Command Write-Info -ErrorAction SilentlyContinue)) { Write-Info 'No LDAPS-usable certificate found. You can create a self-signed Schannel certificate.' } $choice = Read-Choice -Prompt $Prompt -Options @($options) -Labels @($labels) -Default $default if ($choice -eq '__CREATE__') { return [PSCustomObject]@{ Thumbprint = $null CreateSelfSigned = $true Entry = $null } } $selected = $accepted | Where-Object { [string]$_.Thumbprint -eq $choice } | Select-Object -First 1 return [PSCustomObject]@{ Thumbprint = $choice CreateSelfSigned = $false Entry = $selected } } function Enable-AdLdaps { param( [string]$PemOutputPath, [string]$Thumbprint, [string[]]$DnsName, [switch]$RestartNtds, [switch]$CreateSelfSigned, [switch]$NonInteractive, [string]$InstallPath ) Assert-Elevated -Operation 'EnableLdaps' $dc = Assert-AdDomainController Write-Ok $dc.Reason if (-not $PemOutputPath) { if ($InstallPath) { $PemOutputPath = Join-Path $InstallPath 'va-certificates' } else { $PemOutputPath = 'C:\SailPoint\va-certificates' } } Write-Step 'Selecting LDAPS certificate (Server Auth + DigitalSignature + KeyEncipherment + KeyExchange)' $created = $false $cert = $null $storeName = $null # Purge SailPoint self-signed LDAPS certs that cannot present a private key. $null = Clear-AdLdapsUnusableManagedCertificates if ($CreateSelfSigned) { if ($Thumbprint) { throw 'Specify either -Thumbprint or -CreateSelfSigned, not both.' } Write-Info 'Creating a new self-signed Schannel certificate as requested.' $cert = New-AdLdapsSelfSignedCertificate -DnsName $DnsName $created = $true $storeName = 'My' } else { $found = Find-AdLdapsCertificate -Thumbprint $Thumbprint -DnsName $DnsName $cert = $found.Certificate $storeName = $found.StoreName if (-not $cert) { if ($Thumbprint) { throw "No usable LDAPS certificate matched thumbprint '$Thumbprint'." } Write-Info 'No existing LDAPS-capable certificate found; creating a self-signed Schannel certificate.' $cert = New-AdLdapsSelfSignedCertificate -DnsName $DnsName $created = $true $storeName = 'My' } else { Write-Ok "Using certificate $($cert.Thumbprint) from LocalMachine\$storeName" } } $machineDns = Get-AdLdapsMachineDnsFullyQualifiedName Write-Info "AD DS machine DNS name (must match cert SAN/CN): $machineDns" Write-Step 'Validating certificate for Schannel / LDAPS selection' $schannel = Test-AdLdapsCertificateSchannelReady -Certificate $cert -DnsName $machineDns if (-not $schannel.Ok) { foreach ($reason in $schannel.Reasons) { Write-Host " $reason" -ForegroundColor Yellow } throw "Certificate $($cert.Thumbprint) is not ready for AD DS LDAPS. Fix the issues above (especially DNS name '$machineDns'), or choose Create self-signed." } Write-Ok "Certificate passes Schannel readiness checks for '$machineDns'." Write-Step 'Installing certificate into NTDS\My (private key) and removing competing NTDS certs' $myStore = New-Object System.Security.Cryptography.X509Certificates.X509Store('My', 'LocalMachine') $myStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) try { $inMy = $myStore.Certificates | Where-Object { ([string]$_.Thumbprint -replace '\s', '').ToUpperInvariant() -eq ([string]$cert.Thumbprint -replace '\s', '').ToUpperInvariant() } | Select-Object -First 1 if (-not $inMy) { $myStore.Add($cert) Write-Ok 'Copied certificate into LocalMachine\My' } } finally { $myStore.Close() } $ntdsInstall = Install-AdLdapsCertificateIntoNtdsServiceStore -Thumbprint $cert.Thumbprint -RemoveCompetingCertificates $ntdsLinked = [bool]$ntdsInstall.Added -or ($ntdsInstall.RemovedThumbprints.Count -gt 0) if ($ntdsInstall.RemovedThumbprints.Count -gt 0) { Write-Ok "Removed $($ntdsInstall.RemovedThumbprints.Count) competing certificate(s) from NTDS\My (e.g. RabbitMQ / short-name)." } $renewed = Invoke-AdLdapsCertificateRenewal Write-Step 'Ensuring inbound LDAPS firewall rule' $fwCreated = $false try { $fwCreated = Enable-AdLdapsFirewallRule } catch { Write-Info "Could not create firewall rule (may need manual allow for TCP $($script:LdapsPort)): $($_.Exception.Message)" } $ntdsRestarted = $false $ntdsStatus = $dc.NtdsStatus Start-Sleep -Seconds 1 $portCheck = Test-AdLdapsPort -Port $script:LdapsPort $tlsCheck = Test-AdLdapsTlsHandshake -HostName $machineDns -Port $script:LdapsPort -ExpectedThumbprint $cert.Thumbprint $needsRestart = $created -or $ntdsLinked -or -not $portCheck.Listening -or -not $tlsCheck.Ok if ($needsRestart) { $shouldRestart = [bool]$RestartNtds if (-not $NonInteractive -and -not $RestartNtds) { $shouldRestart = Read-YesNo -Prompt "Restart NTDS so AD DS presents the LDAPS certificate on TCP $($script:LdapsPort)? (brief AD outage)" -Default $true } elseif ($NonInteractive -and -not $RestartNtds -and (-not $portCheck.Listening -or -not $tlsCheck.Ok)) { Write-Info "LDAPS is not healthy yet. Pass -RestartNtds (recommended) after installing the certificate." } if ($shouldRestart) { $ntdsStatus = Restart-AdNtdsService $ntdsRestarted = $true Start-Sleep -Seconds 3 $renewed = (Invoke-AdLdapsCertificateRenewal) -or $renewed Start-Sleep -Seconds 2 $portCheck = Test-AdLdapsPort -Port $script:LdapsPort $tlsCheck = Test-AdLdapsTlsHandshake -HostName $machineDns -Port $script:LdapsPort -ExpectedThumbprint $cert.Thumbprint } } Write-Step "Exporting certificate chain as PEM to $PemOutputPath" $export = Export-AdLdapsCertificateChain -Certificate $cert -OutputPath $PemOutputPath Write-Ok "Wrote $($export.Files.Count) PEM file(s); chain: $($export.ChainPath)" $result = [PSCustomObject]@{ Thumbprint = [string]$cert.Thumbprint StoreName = $storeName DnsNames = @(Get-AdLdapsCertificateDnsNames -Certificate $cert) MachineDnsName = $machineDns CreatedCertificate = $created NtdsServiceLinked = [bool](Test-AdLdapsCertificateInNtdsServiceStore -Thumbprint $cert.Thumbprint) NtdsLinkCreated = [bool]$ntdsInstall.Added CompetingRemoved = @($ntdsInstall.RemovedThumbprints) RenewServerCertificate = $renewed FirewallRuleCreated = $fwCreated NtdsRestarted = $ntdsRestarted NtdsStatus = $ntdsStatus PortCheck = $portCheck TlsCheck = $tlsCheck Export = $export SchannelCheck = $schannel } $healthy = $portCheck.Listening -and $tlsCheck.Ok if ($healthy) { Write-Ok $portCheck.Detail Write-Ok $tlsCheck.Detail Show-AdLdapsCompletion -Result $result return $result } $diag = [System.Collections.Generic.List[string]]::new() if (-not $portCheck.Listening) { $diag.Add("TCP $($script:LdapsPort) is not listening: $($portCheck.Detail)") } else { $diag.Add("TCP $($script:LdapsPort) is open, but TLS is not healthy: $($tlsCheck.Detail)") $diag.Add('lsass can listen on 636 while presenting a wrong cert (RabbitMQ/FAM/short-name). Clients then fall back to LDAP 389.') } $diag.Add("Machine DNS name AD DS expects: $machineDns") $diag.Add("Certificate thumbprint: $($cert.Thumbprint)") $diag.Add("Certificate DNS names: $((@(Get-AdLdapsCertificateDnsNames -Certificate $cert)) -join ', ')") $diag.Add("In NTDS service store: $($result.NtdsServiceLinked)") $diag.Add('Run: certutil -store -service NTDS My') $diag.Add("Run: Test-AdLdapsTlsHandshake -HostName $machineDns -ExpectedThumbprint $($cert.Thumbprint)") $diag.Add('If still failing after NTDS restart, reboot the domain controller once.') foreach ($line in $diag) { Write-Host " $line" -ForegroundColor Yellow } Show-AdLdapsCompletion -Result $result throw ($diag -join ' ') } Export-ModuleMember -Function @( 'Get-AdLdapsHostNames' 'Get-AdLdapsMachineDnsFullyQualifiedName' 'Test-AdDomainController' 'Assert-AdDomainController' 'Get-AdLdapsCertificateDnsNames' 'Test-AdLdapsCertificateNameMatch' 'Get-AdLdapsKeySpec' 'Get-AdLdapsEnhancedKeyUsages' 'Test-AdLdapsCertificateHasPrivateKey' 'Test-AdLdapsManagedCertificate' 'Get-AdLdapsKeyUsageFlags' 'Test-AdLdapsCertificateUses' 'Test-AdLdapsCertificateCandidate' 'Get-AdLdapsCertificateStoreCandidates' 'Find-AdLdapsCertificate' 'Format-AdLdapsCertificateChoiceLabel' 'Select-AdLdapsCertificate' 'Get-AdLdapsNtdsServiceCertificateRegistryPath' 'Get-AdLdapsLocalMachineCertificateRegistryPath' 'Test-AdLdapsCertificateInNtdsServiceStore' 'Initialize-AdLdapsCertOpenStoreType' 'Open-AdLdapsServiceCertificateStore' 'Get-AdLdapsCertificateFromLocalMachineMy' 'Test-AdLdapsCertificateIsSelfSigned' 'Remove-AdLdapsCertificateByThumbprint' 'Clear-AdLdapsUnusableManagedCertificates' 'Clear-AdLdapsManagedCertificates' 'Install-AdLdapsCertificateIntoTrustedRoot' 'Test-AdLdapsCertificateSchannelReady' 'Install-AdLdapsCertificateIntoNtdsServiceStore' 'Invoke-AdLdapsCertificateRenewal' 'New-AdLdapsSelfSignedCertificate' 'Enable-AdLdapsFirewallRule' 'Test-AdLdapsPort' 'Test-AdLdapsTlsHandshake' 'Restart-AdNtdsService' 'ConvertTo-X509Pem' 'Get-AdLdapsCertificateChain' 'Export-AdLdapsCertificateChain' 'Get-AdLdapsVaManualSteps' 'Show-AdLdapsCompletion' 'Enable-AdLdaps' ) |