Private/ConvertTo-KrbKeyNameList.ps1

#Requires -Version 7.4

function ConvertTo-KrbKeyNameList {
    <#
    .SYNOPSIS
        Splits an AvailableKeys event field into a normalised array of key family names

    .DESCRIPTION
        The AccountAvailableKeys, ServiceAvailableKeys and DCAvailableKeys fields of a
        version 2 4768 or 4769 event carry a comma-separated list such as 'AES-SHA1, RC4',
        or the literal 'N/A' when the KDC did not populate them.

        The distinction this function preserves is between an empty list and no list at all:

        - $null means the field was absent or 'N/A'. The event did not tell us which keys
          exist, most likely because the DC emits an older schema version.
        - @() means the field was present and named no keys. That is a principal with no
          usable key material at all.

        Collapsing those two into one value is how an assessment ends up asserting that every
        account on a Server 2016 domain controller has no AES key, when in truth the DC
        simply never wrote the field.

    .PARAMETER Value
        [System.String] (Mandatory, Pipeline: ByValue)

        The raw field text.

    .EXAMPLE
        PS> ConvertTo-KrbKeyNameList -Value 'AES-SHA1, RC4'

        DESCRIPTION: Splits a populated available-keys field
        OUTPUT: @('AES-SHA1', 'RC4')
        USE CASE: Determining that an account holds AES key material

    .EXAMPLE
        PS> ConvertTo-KrbKeyNameList -Value 'N/A'

        DESCRIPTION: Recognises the KDC's not-populated marker
        OUTPUT: $null
        USE CASE: Avoiding a false "no AES keys" finding on a legacy event schema

    .OUTPUTS
        System.String[] or $null

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/

        TROUBLESHOOTING:
        - Missing version 2 fields: .\Troubleshooting\Common\Event-Schema-Versions.md
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Value
    )

    process {
        if ([string]::IsNullOrWhiteSpace($Value)) { return $null }

        $text = $Value.Trim()
        if ($text -in 'N/A', '-') { return $null }

        # A plain loop, not '-split | ForEach-Object | Where-Object'. This runs three times
        # per event - once each for the account, service and DC key lists - and a two-stage
        # cmdlet pipeline costs roughly a millisecond per call, which measured as the single
        # largest remaining cost in the decode path once the obvious ones were gone. The
        # idiomatic form is the right default; at three invocations per record across a
        # multi-hundred-thousand-event collection it is not.
        $result = [System.Collections.Generic.List[string]]::new()

        foreach ($piece in $text.Split(',')) {
            $trimmed = $piece.Trim()
            if ($trimmed) { $result.Add($trimmed) }
        }

        # Unary comma so that a single-key result stays an array. Without it, a principal
        # holding only RC4 returns a bare string, and a downstream '-contains' test against
        # it becomes a substring test that matches nothing.
        , [string[]]$result
    }
}