Public/Get-KrbDomainEtypeContext.ps1

#Requires -Version 7.4

function Get-KrbDomainEtypeContext {
    <#
    .EXTERNALHELP KrbEtypeInsight-Help.xml
    .SYNOPSIS
        Collects the domain-wide settings that govern Kerberos encryption type selection
    #>

    [CmdletBinding()]
    [OutputType('KrbEtypeInsight.DomainContext')]
    param(
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$Server,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]$ComputerName,

        [Parameter()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential,

        [Parameter()]
        [switch]$SkipRegistry
    )

    begin {
        $correlationId = [System.Guid]::NewGuid()
        Write-Verbose "Starting $($MyInvocation.MyCommand.Name) - CorrelationId: $correlationId"

        if (-not (Get-Module -Name ActiveDirectory)) {
            try {
                Import-Module ActiveDirectory -ErrorAction Stop
            }
            catch {
                Write-Error ('The ActiveDirectory module is required and could not be loaded. ' +
                    "Error: $($_.Exception.Message)") -ErrorAction Stop
                return
            }
        }

        $catalog = Get-KrbEtypeCatalog
        $adArgs = @{ ErrorAction = 'Stop' }
        if ($Server) { $adArgs['Server'] = $Server }
        if ($Credential) { $adArgs['Credential'] = $Credential }
    }

    process {
        try {
            $domain = Get-ADDomain @adArgs
            $forest = Get-ADForest @adArgs

            # Functional level is expressed as an enum whose ordinal ordering is meaningful,
            # but comparing the enum directly against a literal is fragile across module
            # versions. Matching the year out of the name is stable and reads clearly.
            $domainModeYear = 0
            if ("$($domain.DomainMode)" -match '(\d{4})') { $domainModeYear = [int]$Matches[1] }

            # Below Windows Server 2008 no account in the domain has an AES key, because the
            # KDC never derived one. This is a whole-domain blocker and outranks every
            # per-account finding an assessment could produce.
            $supportsAesKeyDerivation = ($domainModeYear -ge 2008 -or
                "$($domain.DomainMode)" -match '2008|2012|2016|2025|Unknown')

            Write-Verbose "Domain $($domain.DNSRoot) at functional level $($domain.DomainMode)"

            $krbtgt = Get-ADUser -Identity 'krbtgt' `
                -Properties 'msDS-SupportedEncryptionTypes', 'pwdLastSet', 'userAccountControl' @adArgs

            $krbtgtPasswordSet = if ($krbtgt.pwdLastSet -is [datetime]) { $krbtgt.pwdLastSet }
                                 elseif ($krbtgt.pwdLastSet) { [datetime]::FromFileTime([long]$krbtgt.pwdLastSet) }
                                 else { $null }

            $controllers = @(Get-ADDomainController -Filter * @adArgs)
            if ($ComputerName) {
                $controllers = @($controllers | Where-Object {
                    $_.HostName -in $ComputerName -or $_.Name -in $ComputerName
                })
                if (-not $controllers) {
                    Write-Error "None of the supplied computer names matched a domain controller" -ErrorAction Stop
                    return
                }
            }

            # Registry collection. Per-controller and deliberately fault-tolerant: an
            # unreachable controller becomes a row marked unreachable, not a failed run.
            $dcResults = [System.Collections.Generic.List[object]]::new()

            foreach ($dc in $controllers) {
                $reg = @{
                    DefaultDomainSupportedEncTypes = $null
                    PolicySupportedEncryptionTypes = $null
                    Reachable                      = $false
                    Error                          = $null
                }

                if (-not $SkipRegistry) {
                    $readPolicy = {
                        $result = @{
                            DefaultDomainSupportedEncTypes = $null
                            PolicySupportedEncryptionTypes = $null
                        }

                        # KB5021131 places this value directly under the KDC service key, not
                        # under a Parameters subkey. Reading the wrong path returns null,
                        # which is indistinguishable from "not configured" - and "not
                        # configured" is the answer that makes an assessment assume 0x27.
                        $kdc = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Kdc' `
                            -Name 'DefaultDomainSupportedEncTypes' -ErrorAction SilentlyContinue
                        if ($null -ne $kdc) {
                            $result.DefaultDomainSupportedEncTypes = $kdc.DefaultDomainSupportedEncTypes
                        }

                        # What the "Network security: Configure encryption types allowed for
                        # Kerberos" policy writes. On a domain controller this constrains the
                        # KDC itself, which is why it belongs in a domain baseline and not
                        # only in a client one.
                        $policyPath = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\' +
                            'Policies\System\Kerberos\Parameters'

                        $policy = Get-ItemProperty -Path $policyPath `
                            -Name 'SupportedEncryptionTypes' -ErrorAction SilentlyContinue
                        if ($null -ne $policy) {
                            $result.PolicySupportedEncryptionTypes = $policy.SupportedEncryptionTypes
                        }

                        $result
                    }

                    try {
                        # The local controller is read in-process. Remoting to localhost
                        # requires WinRM to be configured even when the script is running on
                        # the controller itself, and failing there would make the module
                        # unusable in exactly the place it is most likely to be run.
                        $isLocal = $dc.HostName -eq [System.Net.Dns]::GetHostEntry($env:COMPUTERNAME).HostName -or
                                   $dc.Name -eq $env:COMPUTERNAME

                        $values = if ($isLocal) {
                            & $readPolicy
                        }
                        else {
                            $icmArgs = @{
                                ComputerName = $dc.HostName
                                ScriptBlock  = $readPolicy
                                ErrorAction  = 'Stop'
                            }
                            if ($Credential) { $icmArgs['Credential'] = $Credential }
                            Invoke-Command @icmArgs
                        }

                        $reg.DefaultDomainSupportedEncTypes =
                            ConvertTo-KrbInt32 -Value $values.DefaultDomainSupportedEncTypes
                        $reg.PolicySupportedEncryptionTypes =
                            ConvertTo-KrbInt32 -Value $values.PolicySupportedEncryptionTypes
                        $reg.Reachable = $true
                    }
                    catch {
                        $reg.Error = $_.Exception.Message
                        Write-Warning ("Could not read Kerberos policy from $($dc.HostName): " +
                            "$($_.Exception.Message). Its contribution to the domain baseline is unknown.")
                    }
                }

                # The version 2 audit schema and the RFC 8009 SHA-2 encryption types both
                # depend on the controller build, so the report can explain a missing field
                # rather than treating it as a finding about the accounts.
                $osVersion = $null
                if ($dc.OperatingSystemVersion -match '^(\d+)\.(\d+)\s*\((\d+)\)') {
                    $osVersion = [int]$Matches[3]
                }

                $dcResults.Add([PSCustomObject]@{
                    PSTypeName = 'KrbEtypeInsight.DomainControllerContext'
                    Name       = $dc.Name
                    HostName   = $dc.HostName
                    Site       = $dc.Site
                    OperatingSystem = $dc.OperatingSystem
                    OperatingSystemBuild = $osVersion
                    IsReadOnly = $dc.IsReadOnly

                    # RFC 8009 SHA-2 encryption types arrived with Windows Server 2025
                    # (build 26100). Below that a SHA-2 etype in a report can only have come
                    # from a non-Windows KDC.
                    SupportsSha2Etypes = if ($osVersion) { $osVersion -ge 26100 } else { $null }

                    RegistryReachable = $reg.Reachable
                    RegistryError     = $reg.Error
                    DefaultDomainSupportedEncTypes = $reg.DefaultDomainSupportedEncTypes
                    DefaultDomainSupportedEncTypesDecoded = if ($null -ne $reg.DefaultDomainSupportedEncTypes) {
                        ConvertFrom-KrbEtype -SupportedEncryptionTypes $reg.DefaultDomainSupportedEncTypes
                    } else { $null }
                    PolicySupportedEncryptionTypes = $reg.PolicySupportedEncryptionTypes
                    PolicySupportedEncryptionTypesDecoded = if ($null -ne $reg.PolicySupportedEncryptionTypes) {
                        ConvertFrom-KrbEtype -SupportedEncryptionTypes $reg.PolicySupportedEncryptionTypes
                    } else { $null }
                    AgreesWithDomainDefault = $null   # filled in below, once the majority is known
                })
            }

            # Establish the effective domain default by majority vote. Where controllers
            # disagree, the most common EFFECTIVE value wins and the disagreement becomes a
            # finding - picking the first controller's value instead would make the whole
            # assessment depend on which controller happened to answer first.
            #
            # Every REACHABLE controller votes, including one whose registry value is absent.
            # That is the correction a real two-controller lab forced. An unset controller is
            # not abstaining: the KDC on it falls back to the documented Windows default of
            # 0x27, which is as much a position as any explicit value. Counting only the
            # explicitly-configured controllers - which is what this did - produces two
            # specific failures, and a domain with one configured and one unset controller
            # hits both at once:
            #
            # 1. One controller on 0x1C and one unset yields a single "configured" entry,
            # so the group count is 1 and ControllersDisagreeOnDefault comes back FALSE
            # while the controllers genuinely disagree. That is the exact condition the
            # property exists to detect, silently inverted.
            # 2. The domain default resolves to 0x1C, so the UNSET controller then fails the
            # agreement test and is named as the outlier - while the controller actually
            # holding the divergent value is reported as agreeing.
            #
            # An UNREACHABLE controller is different again and must not vote. Its value is
            # unknown, not default, and folding it into the tally would let a firewalled or
            # RPC-blocked controller manufacture a majority. It is scored $null below.
            $voting = @($dcResults | Where-Object { $_.RegistryReachable -or $SkipRegistry })

            $votes = @(
                foreach ($row in $voting) {
                    if ($null -ne $row.DefaultDomainSupportedEncTypes) {
                        $row.DefaultDomainSupportedEncTypes
                    }
                    else {
                        $catalog.DefaultDomainSupportedEncTypes
                    }
                }
            )

            $domainDefault = $catalog.DefaultDomainSupportedEncTypes
            $domainDefaultSource = 'WindowsDefault'
            $controllersDisagree = $false

            if ($votes.Count -gt 0) {
                $grouped = @($votes | Group-Object | Sort-Object Count -Descending)
                $controllersDisagree = ($grouped.Count -gt 1)

                # Ties are resolved toward the Windows default rather than arbitrarily.
                #
                # A two-controller domain with one value set and one unset is a 1-1 split, and
                # there is no majority to take. Sort-Object then returns whichever group it
                # happened to order first, which made the outlier identification a coin flip -
                # and on the real pair it landed on the wrong one, naming the UNSET controller
                # as deviating from a default derived solely from the OTHER controller's
                # explicit value.
                #
                # The tie-break encodes what an administrator means by "the domain default":
                # an unset controller is running documented Windows behaviour, and a
                # controller carrying an explicit different value is the deliberate deviation.
                # So where the tied values include the Windows default, that wins. Where they
                # do not, the lowest value wins - arbitrary, but deterministic, which matters
                # more than which one is chosen when nothing distinguishes them.
                $top = @($grouped | Where-Object { $_.Count -eq $grouped[0].Count })

                if ($top.Count -eq 1) {
                    $domainDefault = [int]$top[0].Name
                }
                elseif (@($top | Where-Object { [int]$_.Name -eq $catalog.DefaultDomainSupportedEncTypes })) {
                    $domainDefault = $catalog.DefaultDomainSupportedEncTypes
                }
                else {
                    $domainDefault = [int](@($top.Name | Sort-Object { [int]$_ })[0])
                }

                # Sourced from the registry only if at least one controller actually had the
                # value written. A domain where every controller is unset is running the
                # documented default, and saying otherwise would present an assumption as a
                # measurement.
                if (@($voting | Where-Object { $null -ne $_.DefaultDomainSupportedEncTypes }).Count -gt 0) {
                    $domainDefaultSource = 'Registry'
                }
            }

            foreach ($row in $dcResults) {
                # Three states, and the third is the one that was missing. A controller whose
                # registry could not be read has an UNKNOWN position, not an agreeing one.
                # Scoring it $true - which is what comparing its absent value against the
                # Windows default did - means a firewalled or RPC-blocked controller reads as
                # consensus, and the operator sees agreement built partly from controllers
                # nobody actually asked. $null keeps "unknown" distinct from "agrees", and
                # RegistryError already carries the reason.
                $row.AgreesWithDomainDefault = if (-not ($row.RegistryReachable -or $SkipRegistry)) {
                    $null
                }
                elseif ($null -eq $row.DefaultDomainSupportedEncTypes) {
                    # Reachable and unset. The KDC falls back to the Windows default, so this
                    # controller agrees when that is what the domain resolved to.
                    $domainDefault -eq $catalog.DefaultDomainSupportedEncTypes
                }
                else {
                    $row.DefaultDomainSupportedEncTypes -eq $domainDefault
                }
            }

            if ($controllersDisagree) {
                Write-Warning ('Domain controllers do not agree on DefaultDomainSupportedEncTypes. ' +
                    'Clients will authenticate differently depending on which controller they reach, ' +
                    'which produces intermittent failures that cannot be reproduced on demand. ' +
                    'Inspect the DomainControllers collection on the returned object.')
            }

            # Trusts. A trust that omits AES pins every cross-realm ticket to RC4 no matter
            # what the accounts on either side support.
            $trusts = @(
                try {
                    Get-ADTrust -Filter * -Properties 'msDS-SupportedEncryptionTypes', 'trustAttributes' @adArgs |
                        ForEach-Object {
                            $trustEtypes = $_.'msDS-SupportedEncryptionTypes'
                            [PSCustomObject]@{
                                PSTypeName  = 'KrbEtypeInsight.TrustContext'
                                Name        = $_.Name
                                Target      = $_.Target
                                Direction   = $_.Direction
                                TrustType   = $_.TrustType
                                IsIntraForest = $_.IntraForest
                                SelectiveAuthentication = $_.SelectiveAuthentication
                                SupportedEncryptionTypesRaw = $trustEtypes
                                EncryptionTypes = ConvertFrom-KrbEtype -SupportedEncryptionTypes $trustEtypes `
                                    -DomainDefaultEncryptionTypes $domainDefault

                                # An intra-forest trust inherits the forest's behaviour and is
                                # rarely the problem. An external or forest trust with no AES
                                # bit is a hard blocker for cross-realm hardening and needs
                                # the other side's cooperation to fix, which makes it the
                                # longest-lead item in most projects.
                                IsCrossRealmRc4Risk = (-not $_.IntraForest) -and
                                    (-not (ConvertFrom-KrbEtype -SupportedEncryptionTypes $trustEtypes `
                                        -DomainDefaultEncryptionTypes $domainDefault).SupportsAes)
                            }
                        }
                }
                catch {
                    Write-Warning "Could not enumerate trusts: $($_.Exception.Message)"
                }
            )

            $krbtgtEtypes = ConvertFrom-KrbEtype `
                -SupportedEncryptionTypes $krbtgt.'msDS-SupportedEncryptionTypes' `
                -DomainDefaultEncryptionTypes $domainDefault

            [PSCustomObject]@{
                PSTypeName = 'KrbEtypeInsight.DomainContext'

                DomainName     = $domain.DNSRoot
                NetBiosName    = $domain.NetBIOSName
                DomainSid      = $domain.DomainSID.Value
                DomainMode     = "$($domain.DomainMode)"
                ForestName     = $forest.Name
                ForestMode     = "$($forest.ForestMode)"

                SupportsAesKeyDerivation = $supportsAesKeyDerivation

                DomainDefaultEncryptionTypes = $domainDefault
                DomainDefaultDecoded = ConvertFrom-KrbEtype -SupportedEncryptionTypes $domainDefault

                # Whether the baseline was measured or assumed. Every downstream conclusion
                # about an account with an unset attribute inherits this uncertainty, so it
                # travels with the data rather than living only in a log line.
                DomainDefaultSource = $domainDefaultSource
                ControllersDisagreeOnDefault = $controllersDisagree

                KrbtgtEncryptionTypes = $krbtgtEtypes
                KrbtgtSupportedEncryptionTypesRaw = $krbtgt.'msDS-SupportedEncryptionTypes'
                KrbtgtPasswordLastSet = $krbtgtPasswordSet
                KrbtgtPasswordAgeDays = if ($krbtgtPasswordSet) {
                    [int]((Get-Date) - $krbtgtPasswordSet).TotalDays
                } else { $null }

                DomainControllers = @($dcResults)
                Trusts            = $trusts
                TrustsWithRc4Only = @($trusts | Where-Object { $_.IsCrossRealmRc4Risk })

                CollectedAt   = Get-Date
                CorrelationId = $correlationId
            }
        }
        catch {
            $errorDetails = @{
                CorrelationId = $correlationId
                Function      = $MyInvocation.MyCommand.Name
                ErrorMessage  = $_.Exception.Message
                Line          = $_.InvocationInfo.ScriptLineNumber
            }
            Write-Verbose ('Domain context failure detail: ' + ($errorDetails | ConvertTo-Json -Compress))
            Write-Error ("Failed to collect domain encryption type context: " +
                $_.Exception.Message) -ErrorAction Stop
        }
    }

    end {
        Write-Verbose "Completed $($MyInvocation.MyCommand.Name) - CorrelationId: $correlationId"
    }
}