Public/Get-KrbPrincipalEtype.ps1

#Requires -Version 7.4

function Get-KrbPrincipalEtype {
    <#
    .EXTERNALHELP KrbEtypeInsight-Help.xml
    .SYNOPSIS
        Reads the configured Kerberos encryption type posture of Active Directory principals
    #>


    # Two false positives from PSReviewUnusedParameter, suppressed with the reasoning here
    # because a SuppressMessageAttribute Justification must be a single unwrappable literal.
    #
    # -All is a parameter set discriminator. It is never read by name because the switch on
    # $PSCmdlet.ParameterSetName is what consumes it; removing it would remove the only way
    # to select the enumeration set.
    #
    # -DomainDefaultEncryptionTypes is used inside the $newPrincipal script block, which the
    # analyzer does not follow into. Removing it would silently judge every account with an
    # unset encryption type attribute against the wrong baseline.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'All',
        Justification = 'Parameter set discriminator. See comment above.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'DomainDefaultEncryptionTypes',
        Justification = 'Used in the newPrincipal script block. See comment above.')]
    [CmdletBinding(DefaultParameterSetName = 'All')]
    [OutputType('KrbEtypeInsight.Principal')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'Identity')]
        [ValidateNotNullOrEmpty()]
        [Alias('SamAccountName', 'DistinguishedName')]
        [string[]]$Identity,

        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName,
            ParameterSetName = 'ServicePrincipalName')]
        [ValidateNotNullOrEmpty()]
        [Alias('ServiceName', 'Spn')]
        [string[]]$ServicePrincipalName,

        [Parameter(Mandatory, ParameterSetName = 'All')]
        [switch]$All,

        [Parameter(ParameterSetName = 'All')]
        [switch]$ServiceAccountOnly,

        [Parameter(ParameterSetName = 'All')]
        [switch]$IncludeDisabled,

        [Parameter()]
        [int]$DomainDefaultEncryptionTypes = 0x27,

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

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

    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. ' +
                    "Install RSAT or run from a domain controller. Error: $($_.Exception.Message)") `
                    -ErrorAction Stop
                return
            }
        }

        # The full property set every path needs. Named explicitly rather than using
        # -Properties * because the wildcard pulls constructed and back-linked attributes
        # that cost the directory real work - memberOf and tokenGroups among them - for data
        # this function never reads.
        $properties = @(
            # sAMAccountName is NOT one of Get-ADObject's default properties - that set is
            # DistinguishedName, Name, ObjectClass and ObjectGUID only. Omitting it here
            # produces principal objects whose SamAccountName is silently empty, which then
            # becomes an empty key in the risk engine's account index and quietly loses the
            # correlation between events and directory configuration for every account.
            'sAMAccountName'
            'msDS-SupportedEncryptionTypes'
            'userAccountControl'
            'servicePrincipalName'
            'pwdLastSet'
            'whenCreated'
            'lastLogonTimestamp'
            'displayName'
            'description'
            'objectClass'
            'objectSid'
        )

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

        $now = Get-Date

        # Local function so the three parameter sets share one construction. Anything that
        # produces a Principal object goes through here, which is what keeps the output
        # schema identical no matter how the account was found.
        $newPrincipal = {
            param($adObject)

            $rawEtypes = $adObject.'msDS-SupportedEncryptionTypes'
            $spns = @($adObject.'servicePrincipalName')

            # pwdLastSet arrives as a FILETIME integer from Get-ADObject and as a DateTime
            # from Get-ADUser. Both shapes reach this function, so both are handled - a
            # missing conversion here silently produces a null password age, which reads in
            # the report as "age unknown" on precisely the accounts whose age matters most.
            $passwordLastSet = $null
            $raw = $adObject.'pwdLastSet'
            if ($raw -is [datetime]) {
                $passwordLastSet = $raw
            }
            elseif ($raw -and $raw -gt 0) {
                $passwordLastSet = [datetime]::FromFileTime([long]$raw)
            }

            $lastLogon = $null
            $rawLogon = $adObject.'lastLogonTimestamp'
            if ($rawLogon -is [datetime]) { $lastLogon = $rawLogon }
            elseif ($rawLogon -and $rawLogon -gt 0) { $lastLogon = [datetime]::FromFileTime([long]$rawLogon) }

            $control = ConvertFrom-KrbAccountControl -UserAccountControl $adObject.'userAccountControl'

            $etypes = ConvertFrom-KrbEtype -SupportedEncryptionTypes $rawEtypes `
                -DomainDefaultEncryptionTypes $DomainDefaultEncryptionTypes

            # objectClass is multi-valued and ordered least to most specific, so the last
            # entry is the one that names what the object actually is. Taking the first
            # returns 'top' for every object in the directory.
            $classes = @($adObject.objectClass)
            $objectClass = if ($classes) { $classes[-1] } else { $null }

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

                SamAccountName    = $adObject.SamAccountName
                DistinguishedName = $adObject.DistinguishedName
                Sid               = if ($adObject.objectSid) { $adObject.objectSid.Value } else { $null }
                ObjectClass       = $objectClass
                DisplayName       = $adObject.displayName
                Description       = $adObject.description

                # Enabled is derived from the account control bit rather than the ADAccount
                # Enabled property, because Get-ADObject does not supply that property and
                # this function must return the same schema whichever cmdlet found the object.
                Enabled           = if ($null -ne $control.Disabled) { -not $control.Disabled } else { $null }

                SupportedEncryptionTypesRaw = $rawEtypes
                EncryptionTypes   = $etypes
                AccountControl    = $control

                ServicePrincipalNames = $spns
                HasSpn            = ($spns.Count -gt 0)

                PasswordLastSet   = $passwordLastSet
                PasswordAgeDays   = if ($passwordLastSet) {
                    [int]($now - $passwordLastSet).TotalDays
                } else { $null }
                WhenCreated       = $adObject.whenCreated
                LastLogonTimestamp = $lastLogon

                # krbtgt is called out because its encryption types govern TGT issuance for
                # the entire domain. It is never a per-account finding and must never be
                # treated as one, but a report that omits it entirely has skipped the single
                # most consequential object in the assessment.
                IsKrbtgt          = ($adObject.SamAccountName -eq 'krbtgt' -or
                                     $adObject.SamAccountName -like 'krbtgt_*')

                CorrelationId     = $correlationId
            }
        }
    }

    process {
        try {
            switch ($PSCmdlet.ParameterSetName) {

                'Identity' {
                    foreach ($id in $Identity) {
                        if (-not $id.Trim()) {
                            Write-Error 'Identity cannot be empty or whitespace'
                            continue
                        }

                        try {
                            $found = Get-ADObject -Identity $id.Trim() -Properties $properties @adArgs
                        }
                        catch {
                            # Get-ADObject -Identity only accepts a DN or GUID. A
                            # sAMAccountName is the form an administrator actually has to
                            # hand, so fall back to a search rather than making the caller
                            # know the difference.
                            $escaped = ConvertTo-KrbLdapFilterValue -Value $id.Trim()
                            $found = @(Get-ADObject -LDAPFilter "(sAMAccountName=$escaped)" `
                                    -Properties $properties @adArgs)

                            if (-not $found) {
                                Write-Error "No account found matching identity '$id'"
                                continue
                            }
                        }

                        foreach ($item in @($found)) { & $newPrincipal $item }
                    }
                }

                'ServicePrincipalName' {
                    foreach ($spn in $ServicePrincipalName) {
                        if (-not $spn.Trim()) {
                            Write-Error 'ServicePrincipalName cannot be empty or whitespace'
                            continue
                        }

                        $escaped = ConvertTo-KrbLdapFilterValue -Value $spn.Trim()
                        $found = @(Get-ADObject -LDAPFilter "(servicePrincipalName=$escaped)" `
                                -Properties $properties @adArgs)

                        if (-not $found) {
                            # A 4769 ServiceName is frequently a bare account name rather
                            # than a full SPN - that is how the KDC writes it when the
                            # request named the account directly - so retry as one before
                            # giving up.
                            $found = @(Get-ADObject -LDAPFilter "(sAMAccountName=$escaped)" `
                                    -Properties $properties @adArgs)
                        }

                        if (-not $found) {
                            Write-Error ("No account owns service principal name '$spn'. An SPN in " +
                                'the event log with no owner in the directory usually means the ' +
                                'account was deleted or the SPN was moved.')
                            continue
                        }

                        if ($found.Count -gt 1) {
                            # Duplicate SPNs are a real and damaging misconfiguration: the KDC
                            # returns KDC_ERR_PRINCIPAL_NOT_UNIQUE and the service fails for
                            # everyone. Worth surfacing here even though it is not an
                            # encryption-type problem, because an assessment is when it gets found.
                            Write-Warning ("Service principal name '$spn' is registered on " +
                                "$($found.Count) accounts, which will cause authentication failures " +
                                'independently of any encryption type change.')
                        }

                        foreach ($item in $found) { & $newPrincipal $item }
                    }
                }

                'All' {
                    $clauses = [System.Collections.Generic.List[string]]::new()
                    $clauses.Add('(|(objectCategory=person)(objectCategory=computer))')

                    if ($ServiceAccountOnly) { $clauses.Add('(servicePrincipalName=*)') }

                    # userAccountControl:1.2.840.113556.1.4.803: is the LDAP_MATCHING_RULE_BIT_AND
                    # operator. Filtering disabled accounts server-side keeps a large domain's
                    # tombstoned service accounts out of the result set entirely rather than
                    # transferring them to be discarded locally.
                    if (-not $IncludeDisabled) {
                        $clauses.Add('(!(userAccountControl:1.2.840.113556.1.4.803:=2))')
                    }

                    $ldapFilter = '(&' + ($clauses -join '') + ')'
                    Write-Verbose "Enumerating principals with filter: $ldapFilter"

                    $found = Get-ADObject -LDAPFilter $ldapFilter -Properties $properties @adArgs

                    $count = 0
                    foreach ($item in $found) {
                        $count++
                        if ($count % 500 -eq 0) {
                            Write-Progress -Activity 'Reading principal encryption types' `
                                -Status "$count accounts processed"
                        }
                        & $newPrincipal $item
                    }

                    Write-Progress -Activity 'Reading principal encryption types' -Completed
                    Write-Verbose "Enumerated $count principal(s)"
                }
            }
        }
        catch {
            $errorDetails = @{
                CorrelationId = $correlationId
                Function      = $MyInvocation.MyCommand.Name
                ErrorMessage  = $_.Exception.Message
                Line          = $_.InvocationInfo.ScriptLineNumber
                ParameterSet  = $PSCmdlet.ParameterSetName
            }
            Write-Verbose ('Directory query failure detail: ' + ($errorDetails | ConvertTo-Json -Compress))
            Write-Error "Directory query failed: $($_.Exception.Message)" -ErrorAction Stop
        }
    }

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