Public/ConvertFrom-KrbEtype.ps1

#Requires -Version 7.4

function ConvertFrom-KrbEtype {
    <#
    .EXTERNALHELP KrbEtypeInsight-Help.xml
    .SYNOPSIS
        Decodes Kerberos encryption type values from any of the three forms Windows emits them in
    #>

    [CmdletBinding(DefaultParameterSetName = 'TicketEtype')]
    [OutputType('KrbEtypeInsight.TicketEtype', ParameterSetName = 'TicketEtype')]
    [OutputType('KrbEtypeInsight.EtypeFlags', ParameterSetName = 'SupportedEncryptionTypes')]
    [OutputType('KrbEtypeInsight.AdvertizedEtypeSet', ParameterSetName = 'AdvertizedEtype')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'TicketEtype')]
        [AllowNull()]
        [AllowEmptyString()]
        [object]$TicketEtype,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'SupportedEncryptionTypes')]
        [AllowNull()]
        [AllowEmptyString()]
        [object]$SupportedEncryptionTypes,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'AdvertizedEtype')]
        [AllowNull()]
        [AllowEmptyString()]
        [string[]]$AdvertizedEtype,

        [Parameter(ParameterSetName = 'SupportedEncryptionTypes')]
        [int]$DomainDefaultEncryptionTypes = 0x27
    )

    begin {
        $catalog = Get-KrbEtypeCatalog
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {

            'TicketEtype' {
                $value = ConvertTo-KrbInt32 -Value $TicketEtype

                if ($null -eq $value) {
                    # An absent field is reported as absent. Returning nothing here would
                    # silently shorten a pipeline and make a 4771 disappear from a count.
                    [PSCustomObject]@{
                        PSTypeName         = 'KrbEtypeInsight.TicketEtype'
                        Value              = $null
                        Hex                = $null
                        DisplayName        = 'Not present'
                        Family             = 'None'
                        Strength           = 'NotApplicable'
                        Rfc                = 'n/a'
                        RemovedByHardening = $false
                        IsRecognized       = $false
                    }
                    return
                }

                $known = $catalog.TicketEtype[$value]

                if ($known) {
                    [PSCustomObject]@{
                        PSTypeName         = 'KrbEtypeInsight.TicketEtype'
                        Value              = $value
                        Hex                = '0x{0:X}' -f $value
                        DisplayName        = $known.DisplayName
                        Family             = $known.Family
                        Strength           = $known.Strength
                        Rfc                = $known.Rfc
                        RemovedByHardening = $known.RemovedByHardening
                        IsRecognized       = $true
                    }
                    return
                }

                # An unrecognised value is surfaced, never swallowed. A new Windows release
                # adding an etype must show up as an explicit unknown in the report rather
                # than as a silently dropped row.
                Write-Verbose "Encryption type $value is not in the catalog"
                [PSCustomObject]@{
                    PSTypeName         = 'KrbEtypeInsight.TicketEtype'
                    Value              = $value
                    Hex                = '0x{0:X}' -f $value
                    DisplayName        = "Unknown ($value)"
                    Family             = 'Unknown'
                    Strength           = 'Unknown'
                    Rfc                = 'Unknown'
                    RemovedByHardening = $false
                    IsRecognized       = $false
                }
            }

            'SupportedEncryptionTypes' {
                $raw = ConvertTo-KrbInt32 -Value $SupportedEncryptionTypes

                # Unset and zero are different states with the same effect, and the
                # distinction is worth keeping: an unset attribute is an account nobody has
                # touched, a zero is an account somebody explicitly zeroed. Both fall through
                # to the domain default, so both get the same effective value.
                $isUnset = $null -eq $raw
                $usesDefault = $isUnset -or $raw -eq 0
                $effective = if ($usesDefault) { $DomainDefaultEncryptionTypes } else { $raw }

                $cipherNames = [System.Collections.Generic.List[string]]::new()
                $capabilityNames = [System.Collections.Generic.List[string]]::new()

                # SortedSet rather than a List piped through Sort-Object -Unique. The result
                # is identical and this decoder runs once per principal and once per event,
                # where a cmdlet pipeline is the difference between an assessment finishing
                # in two minutes and in twenty.
                $ticketEtypes = [System.Collections.Generic.SortedSet[int]]::new()
                $sessionKeyEtypes = [System.Collections.Generic.SortedSet[int]]::new()

                foreach ($bit in $catalog.SortedFlagKeys) {
                    if (($effective -band $bit) -eq 0) { continue }

                    $entry = $catalog.FlagToTicketEtype[$bit]

                    if ($bit -band $catalog.CipherFlagMask) {
                        $cipherNames.Add($entry.Name)
                    }
                    else {
                        $capabilityNames.Add($entry.Name)
                    }

                    foreach ($etype in $entry.TicketEtypes) { [void]$ticketEtypes.Add($etype) }
                    foreach ($etype in $entry.SessionKeyEtypes) { [void]$sessionKeyEtypes.Add($etype) }
                }

                # Bits Microsoft has added since this module was written. Preserved rather
                # than masked away, so an assessment run against a future Windows release
                # reports "I do not understand bit 0x100" instead of quietly under-reporting
                # what an account supports.
                $unknownBits = $effective -band (-bnot $catalog.KnownFlagMask)

                # 0x20 authorises AES256 for the session key only. An account holding 0x20
                # and no other AES bit will issue AES session keys inside RC4 tickets, which
                # looks like AES support in a session-key-only view of the data and like no
                # AES support in a ticket view. Both properties are exposed so the caller
                # cannot accidentally pick the flattering one.
                # SHA1 AES bits only. The RFC 8009 bits 0x40 and 0x80 are excluded because a
                # real Server 2025 KDC was observed refusing to issue a ticket for an account
                # that carried only them - see Get-KrbEtypeCatalog. Counting them as AES
                # support reported an unauthenticatable account as safe.
                $aesTicketBits = 0x08 -bor 0x10
                $sha2Bits = 0x40 -bor 0x80

                [PSCustomObject]@{
                    PSTypeName                = 'KrbEtypeInsight.EtypeFlags'
                    Value                     = $raw
                    Hex                       = if ($isUnset) { $null } else { '0x{0:X}' -f $raw }
                    EffectiveValue            = $effective
                    EffectiveHex              = '0x{0:X}' -f $effective
                    IsUnset                   = $isUnset
                    UsesDomainDefault         = $usesDefault

                    # Masked to known bits before the cast. Casting a value carrying an
                    # undefined bit to a [Flags] enum is a terminating PSInvalidCastException
                    # - "due to enumeration values that are not valid" - not a best-effort
                    # decomposition. Unmasked, the first time Microsoft adds a bit to
                    # msDS-SupportedEncryptionTypes this function would start throwing on any
                    # account that had it, taking the whole assessment down rather than
                    # reporting one unfamiliar bit. UnknownBits below carries what was masked
                    # off, so nothing is lost.
                    Flags                     = [KrbEtypeFlag]($effective -band $catalog.KnownFlagMask)
                    FlagNames                 = @($cipherNames) + @($capabilityNames)
                    CipherNames               = @($cipherNames)
                    CapabilityNames           = @($capabilityNames)
                    TicketEtypes              = [int[]]$ticketEtypes
                    SessionKeyEtypes          = [int[]]$sessionKeyEtypes
                    SupportsDes               = [bool]($effective -band 0x03)
                    SupportsRc4               = [bool]($effective -band 0x04)
                    SupportsAes               = [bool]($effective -band $aesTicketBits)
                    SupportsAesSessionKeyOnly = (($effective -band 0x20) -ne 0) -and
                                                (($effective -band $aesTicketBits) -eq 0)
                    SupportsFast              = [bool]($effective -band 0x00010000)

                    # True when the value carries an RFC 8009 SHA-2 bit. Windows Server 2025
                    # build 26100 was observed NOT honouring these: an account holding only
                    # them received KDC_ERR_ETYPE_NOTSUPP, and one holding 0x80 alongside 0x10
                    # was served etype 0x12 with the 0x80 ignored. The bits are named in
                    # CipherNames so nothing is hidden, but they are excluded from SupportsAes
                    # because on observed Windows they confer no usable ticket encryption.
                    CarriesUnhonouredSha2Bits = [bool]($effective -band $sha2Bits)
                    UnknownBits               = $unknownBits
                    UnknownBitsHex            = if ($unknownBits) { '0x{0:X}' -f $unknownBits } else { $null }
                }
            }

            'AdvertizedEtype' {
                # This branch runs once per collected event, so it is written for throughput
                # rather than for brevity: String.Split against a cached char array instead
                # of a regex, plain loops instead of ForEach-Object and Where-Object, and
                # SortedSet instead of Sort-Object -Unique. Written the idiomatic way it
                # measured at 1.7 ms per call, which is several minutes across a real
                # collection and was the single largest cost in the whole decode path.
                $names = [System.Collections.Generic.List[string]]::new()

                foreach ($chunk in $AdvertizedEtype) {
                    if ([string]::IsNullOrWhiteSpace($chunk)) { continue }

                    foreach ($piece in $chunk.Split($catalog.AdvertizedSeparator,
                            [System.StringSplitOptions]::RemoveEmptyEntries)) {
                        $trimmed = $piece.Trim()
                        if ($trimmed) { $names.Add($trimmed) }
                    }
                }

                $resolved = [System.Collections.Generic.SortedSet[int]]::new()
                $families = [System.Collections.Generic.SortedSet[string]]::new(
                    [StringComparer]::OrdinalIgnoreCase)
                $unrecognized = [System.Collections.Generic.List[string]]::new()

                foreach ($name in $names) {
                    # PowerShell hashtables are case-insensitive, so no case folding is
                    # needed on the key - and folding it would cost an allocation per name.
                    $etype = $catalog.AdvertizedName[$name]

                    if ($null -ne $etype) {
                        [void]$resolved.Add($etype)
                        $entry = $catalog.TicketEtype[$etype]
                        if ($entry) { [void]$families.Add($entry.Family) }
                    }
                    else {
                        $unrecognized.Add($name)
                    }
                }

                [PSCustomObject]@{
                    PSTypeName        = 'KrbEtypeInsight.AdvertizedEtypeSet'
                    Names             = [string[]]$names
                    Etypes            = [int[]]$resolved
                    Families          = [string[]]$families
                    SupportsAes       = $families.Contains('AES')
                    SupportsRc4       = $families.Contains('RC4')
                    SupportsDes       = $families.Contains('DES')

                    # The whole point of the module in one property. A client that advertised
                    # at least one algorithm, all of them understood, and none of them AES,
                    # cannot authenticate against a hardened KDC. This is direct evidence,
                    # not an inference from what the KDC happened to choose.
                    #
                    # The unrecognised-names clause is what keeps it honest. Without it, a
                    # client advertising only names this catalog has never heard of resolves
                    # to no families at all, which is indistinguishable from "no AES" - so a
                    # client running an algorithm newer than the module would be reported as
                    # legacy-only and raise a Critical saying it advertises no AES support.
                    # That is exactly the unknown-read-as-negative mistake the null handling
                    # everywhere else in this module exists to prevent, and it is worse here
                    # because it fires on the most modern clients in the estate.
                    #
                    # An advertisement containing anything unrecognised therefore yields
                    # $false - meaning "not established", not "capable". Callers that need
                    # the three-way distinction read SupportsAes and UnrecognizedNames.
                    LegacyOnly        = ($names.Count -gt 0) -and
                                        ($unrecognized.Count -eq 0) -and
                                        (-not $families.Contains('AES'))

                    UnrecognizedNames = [string[]]$unrecognized
                }
            }
        }
    }
}