Private/PSPkinit/PkinitMessages.ps1

<#
    Kerberos (RFC 4120) and PKINIT (RFC 4556) message construction and parsing,
    built on the DER helpers in Asn1.ps1.
#>


function ConvertTo-KerberosPrincipalName {
    <#
        .SYNOPSIS
        PrincipalName ::= SEQUENCE { name-type [0] Int32, name-string [1] SEQUENCE OF KerberosString }
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [int]$NameType,
        [Parameter(Mandatory)] [string[]]$NameStrings
    )

    $nameStringChildren = $NameStrings | ForEach-Object { ConvertTo-Asn1GeneralString -Value $_ }
    $nameStringSeq = ConvertTo-Asn1Sequence -Children $nameStringChildren

    return ConvertTo-Asn1Sequence -Children @(
        (ConvertTo-Asn1ContextExplicit -TagNumber 0 -InnerTlv (ConvertTo-Asn1Integer -Value $NameType))
        (ConvertTo-Asn1ContextExplicit -TagNumber 1 -InnerTlv $nameStringSeq)
    )
}

function ConvertTo-KerberosFlags {
    <#
        .SYNOPSIS
        KerberosFlags ::= BIT STRING (SIZE(32..MAX)) - always sent as exactly 4 octets
        (32 bits) by this module since no options beyond the low 32 bits are used.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter()] [uint32]$Value = 0
    )

    $bytes = [BitConverter]::GetBytes($Value)
    if ([BitConverter]::IsLittleEndian) {
        [Array]::Reverse($bytes)
    }
    return ConvertTo-Asn1BitString -Bytes $bytes
}

function New-KdcReqBody {
    <#
        .SYNOPSIS
        Builds a KDC-REQ-BODY (RFC 4120 section 5.4.1) for an AS-REQ.
 
        .PARAMETER ClientNameType
        Kerberos name-type for cname (RFC 4120 7.5.8). Defaults to NT-PRINCIPAL (1).
        For PKINIT client certificates bound via UPN (the common Windows/AD case),
        use NT-ENTERPRISE (10) with -ClientName set to the full UPN
        (e.g. 'user@adcs.goat') - a plain sAMAccountName-style name with an NT-PRINCIPAL
        type will not resolve and the KDC returns KDC_ERR_C_PRINCIPAL_UNKNOWN.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [string]$ClientName,
        [Parameter(Mandatory)] [string]$Realm,
        [Parameter(Mandatory)] [uint32]$Nonce,
        [Parameter()] [int[]]$EncryptionTypes = @(18, 17),
        [Parameter()] [uint32]$KdcOptions = 0,
        [Parameter()] [int]$ClientNameType = 1
    )

    $sname = ConvertTo-KerberosPrincipalName -NameType 2 -NameStrings @('krbtgt', $Realm)
    $cname = ConvertTo-KerberosPrincipalName -NameType $ClientNameType -NameStrings @($ClientName)

    # Special epoch value meaning "the maximum endtime permitted by KDC policy" (RFC 4120 5.4.1).
    $till = [datetime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)

    $etypeChildren = $EncryptionTypes | ForEach-Object { ConvertTo-Asn1Integer -Value $_ }

    $children = @(
        (ConvertTo-Asn1ContextExplicit -TagNumber 0 -InnerTlv (ConvertTo-KerberosFlags -Value $KdcOptions))
        (ConvertTo-Asn1ContextExplicit -TagNumber 1 -InnerTlv $cname)
        (ConvertTo-Asn1ContextExplicit -TagNumber 2 -InnerTlv (ConvertTo-Asn1GeneralString -Value $Realm))
        (ConvertTo-Asn1ContextExplicit -TagNumber 3 -InnerTlv $sname)
        (ConvertTo-Asn1ContextExplicit -TagNumber 5 -InnerTlv (ConvertTo-Asn1GeneralizedTime -Value $till))
        (ConvertTo-Asn1ContextExplicit -TagNumber 7 -InnerTlv (ConvertTo-Asn1Integer -Value ([long]$Nonce)))
        (ConvertTo-Asn1ContextExplicit -TagNumber 8 -InnerTlv (ConvertTo-Asn1Sequence -Children $etypeChildren))
    )

    return ConvertTo-Asn1Sequence -Children $children
}

function New-AsReq {
    <#
        .SYNOPSIS
        Builds a complete AS-REQ ::= [APPLICATION 10] KDC-REQ, with optional PA-DATA.
 
        .PARAMETER PaData
        Array of already-encoded PA-DATA TLVs (see New-PaData) to include in the
        padata [3] field. May be empty/omitted for an unauthenticated first request.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$KdcReqBody,
        [Parameter()] [byte[][]]$PaData = @()
    )

    $children = [System.Collections.Generic.List[byte[]]]::new()
    $children.Add((ConvertTo-Asn1ContextExplicit -TagNumber 1 -InnerTlv (ConvertTo-Asn1Integer -Value 5)))
    $children.Add((ConvertTo-Asn1ContextExplicit -TagNumber 2 -InnerTlv (ConvertTo-Asn1Integer -Value 10)))
    if ($PaData.Count -gt 0) {
        $paDataSeq = ConvertTo-Asn1Sequence -Children $PaData
        $children.Add((ConvertTo-Asn1ContextExplicit -TagNumber 3 -InnerTlv $paDataSeq))
    }
    $children.Add((ConvertTo-Asn1ContextExplicit -TagNumber 4 -InnerTlv $KdcReqBody))

    $kdcReq = ConvertTo-Asn1Sequence -Children $children.ToArray()
    return ConvertTo-Asn1ApplicationConstructed -TagNumber 10 -InnerTlv $kdcReq
}

function New-PaData {
    <#
        .SYNOPSIS
        PA-DATA ::= SEQUENCE { padata-type [1] Int32, padata-value [2] OCTET STRING }
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [int]$PaDataType,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]]$PaDataValue
    )

    return ConvertTo-Asn1Sequence -Children @(
        (ConvertTo-Asn1ContextExplicit -TagNumber 1 -InnerTlv (ConvertTo-Asn1Integer -Value $PaDataType))
        (ConvertTo-Asn1ContextExplicit -TagNumber 2 -InnerTlv (ConvertTo-Asn1OctetString -Bytes $PaDataValue))
    )
}

function Send-KerberosTcpMessage {
    <#
        .SYNOPSIS
        Sends a DER-encoded Kerberos message to a KDC over TCP (RFC 4120 7.2.2:
        4-byte big-endian length prefix, high bit reserved/zero) and returns the
        raw response message bytes (length prefix stripped).
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [string]$KdcHostname,
        [Parameter()] [int]$Port = 88,
        [Parameter(Mandatory)] [byte[]]$Message,
        [Parameter()] [int]$TimeoutSeconds = 10
    )

    $client = [System.Net.Sockets.TcpClient]::new()
    try {
        $connectTask = $client.ConnectAsync($KdcHostname, $Port)
        if (-not $connectTask.Wait([TimeSpan]::FromSeconds($TimeoutSeconds))) {
            throw "Timed out connecting to $KdcHostname`:$Port"
        }

        $stream = $client.GetStream()

        $lengthPrefix = [BitConverter]::GetBytes([uint32]$Message.Length)
        if ([BitConverter]::IsLittleEndian) {
            [Array]::Reverse($lengthPrefix)
        }
        $stream.Write($lengthPrefix, 0, 4)
        $stream.Write($Message, 0, $Message.Length)
        $stream.Flush()

        $lengthBuffer = New-Object byte[] 4
        $readTotal = 0
        while ($readTotal -lt 4) {
            $readTask = $stream.ReadAsync($lengthBuffer, $readTotal, 4 - $readTotal)
            if (-not $readTask.Wait([TimeSpan]::FromSeconds($TimeoutSeconds))) {
                throw "Timed out reading response length from $KdcHostname`:$Port"
            }
            $n = $readTask.Result
            if ($n -eq 0) { throw 'Connection closed while reading response length.' }
            $readTotal += $n
        }
        if ([BitConverter]::IsLittleEndian) {
            [Array]::Reverse($lengthBuffer)
        }
        $responseLength = [BitConverter]::ToUInt32($lengthBuffer, 0)

        $responseBuffer = New-Object byte[] $responseLength
        $readTotal = 0
        while ($readTotal -lt $responseLength) {
            $readTask = $stream.ReadAsync($responseBuffer, $readTotal, $responseLength - $readTotal)
            if (-not $readTask.Wait([TimeSpan]::FromSeconds($TimeoutSeconds))) {
                throw "Timed out reading response body from $KdcHostname`:$Port"
            }
            $n = $readTask.Result
            if ($n -eq 0) { throw 'Connection closed while reading response body.' }
            $readTotal += $n
        }

        return $responseBuffer
    } finally {
        $client.Dispose()
    }
}

function ConvertFrom-PaData {
    <#
        .SYNOPSIS
        Decodes a single PA-DATA element (RFC 4120 5.2.7:
        SEQUENCE { padata-type [1] Int32, padata-value [2] OCTET STRING })
        into its type number and raw value bytes.
 
        .PARAMETER Content
        The content bytes of the PA-DATA SEQUENCE TLV (i.e. Read-Asn1Tlv's
        .Content for a single child of the padata SEQUENCE OF).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$Content
    )

    $fields = Read-Asn1Sequence -Content $Content
    $result = [PSCustomObject]@{ Type = $null; Value = $null }
    foreach ($field in $fields) {
        $tagNumber = $field.Tag -band 0x1f
        $inner = Read-Asn1Tlv -Bytes $field.Content -Offset 0
        switch ($tagNumber) {
            1 { $result.Type = [int](ConvertFrom-Asn1Integer -Content $inner.Content) }
            2 { $result.Value = $inner.Content }
            default { }
        }
    }
    return $result
}

function ConvertFrom-KerberosPrincipalName {
    <#
        .SYNOPSIS
        Decodes a PrincipalName (RFC 4120 5.2.2) into its name-type and
        name-string components.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$Content
    )

    $fields = Read-Asn1Sequence -Content $Content
    $result = [PSCustomObject]@{ NameType = $null; NameStrings = @() }
    foreach ($field in $fields) {
        $tagNumber = $field.Tag -band 0x1f
        $inner = Read-Asn1Tlv -Bytes $field.Content -Offset 0
        switch ($tagNumber) {
            0 { $result.NameType = [int](ConvertFrom-Asn1Integer -Content $inner.Content) }
            1 {
                $strs = Read-Asn1Sequence -Content $inner.Content
                $result.NameStrings = @($strs | ForEach-Object { ConvertFrom-Asn1GeneralString -Content $_.Content })
            }
            default { }
        }
    }
    return $result
}

function ConvertFrom-EncryptedData {
    <#
        .SYNOPSIS
        Decodes an EncryptedData (RFC 4120 5.2.9:
        SEQUENCE { etype [0] Int32, kvno [1] UInt32 OPTIONAL, cipher [2] OCTET STRING }).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$Content
    )

    $fields = Read-Asn1Sequence -Content $Content
    $result = [PSCustomObject]@{ EType = $null; Kvno = $null; Cipher = $null }
    foreach ($field in $fields) {
        $tagNumber = $field.Tag -band 0x1f
        $inner = Read-Asn1Tlv -Bytes $field.Content -Offset 0
        switch ($tagNumber) {
            0 { $result.EType = [int](ConvertFrom-Asn1Integer -Content $inner.Content) }
            1 { $result.Kvno = [int](ConvertFrom-Asn1Integer -Content $inner.Content) }
            2 { $result.Cipher = $inner.Content }
            default { }
        }
    }
    return $result
}

function ConvertFrom-KdcRep {
    <#
        .SYNOPSIS
        Parses a KDC-REP (RFC 4120 5.4.2 - AS-REP or TGS-REP) into its
        component fields.
 
        .PARAMETER ExpectedApplicationTag
        11 for AS-REP, 13 for TGS-REP.
 
        .OUTPUTS
        PSCustomObject with PvNo, MsgType, PaData (array of type/value pairs
        from ConvertFrom-PaData), CRealm, CName (from ConvertFrom-KerberosPrincipalName),
        Ticket (raw [APPLICATION 1] Ticket TLV bytes, opaque/encrypted under the
        service key - not decodable by the client), and EncPart (from ConvertFrom-EncryptedData).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$Message,
        [Parameter(Mandatory)] [int]$ExpectedApplicationTag
    )

    $outer = Read-Asn1Tlv -Bytes $Message -Offset 0
    $expectedTag = [byte](0x60 -bor $ExpectedApplicationTag)
    if ($outer.Tag -ne $expectedTag) {
        throw ("ConvertFrom-KdcRep: expected [APPLICATION {0}] (tag 0x{1:X2}), got tag 0x{2:X2}." -f $ExpectedApplicationTag, $expectedTag, $outer.Tag)
    }
    $seqTlv = Read-Asn1Tlv -Bytes $outer.Content -Offset 0
    $fields = Read-Asn1Sequence -Content $seqTlv.Content

    $result = [PSCustomObject]@{
        PvNo    = $null
        MsgType = $null
        PaData  = @()
        CRealm  = $null
        CName   = $null
        Ticket  = $null
        EncPart = $null
    }

    foreach ($field in $fields) {
        $tagNumber = $field.Tag -band 0x1f
        $inner = Read-Asn1Tlv -Bytes $field.Content -Offset 0
        switch ($tagNumber) {
            0 { $result.PvNo = [int](ConvertFrom-Asn1Integer -Content $inner.Content) }
            1 { $result.MsgType = [int](ConvertFrom-Asn1Integer -Content $inner.Content) }
            2 {
                $paSeq = Read-Asn1Sequence -Content $inner.Content
                $result.PaData = @($paSeq | ForEach-Object { ConvertFrom-PaData -Content $_.Content })
            }
            3 { $result.CRealm = ConvertFrom-Asn1GeneralString -Content $inner.Content }
            4 { $result.CName = ConvertFrom-KerberosPrincipalName -Content $inner.Content }
            5 { $result.Ticket = $field.Content }
            6 { $result.EncPart = ConvertFrom-EncryptedData -Content $inner.Content }
            default { }
        }
    }

    return $result
}

function ConvertFrom-EncKdcRepPart {
    <#
        .SYNOPSIS
        Parses a decrypted EncKDCRepPart (RFC 4120 5.4.2 - the plaintext
        recovered from an AS-REP/TGS-REP's enc-part).
 
        .PARAMETER ExpectedApplicationTag
        25 for EncASRepPart, 26 for EncTGSRepPart.
 
        .OUTPUTS
        PSCustomObject with Key (@{ KeyType; KeyValue }), Nonce, Flags (uint32
        TicketFlags), AuthTime, StartTime, EndTime, RenewTill (datetime or $null),
        SRealm, and SName (from ConvertFrom-KerberosPrincipalName).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$Message,
        [Parameter(Mandatory)] [int]$ExpectedApplicationTag
    )

    $outer = Read-Asn1Tlv -Bytes $Message -Offset 0
    $expectedTag = [byte](0x60 -bor $ExpectedApplicationTag)
    if ($outer.Tag -ne $expectedTag) {
        throw ("ConvertFrom-EncKdcRepPart: expected [APPLICATION {0}] (tag 0x{1:X2}), got tag 0x{2:X2}." -f $ExpectedApplicationTag, $expectedTag, $outer.Tag)
    }
    $seqTlv = Read-Asn1Tlv -Bytes $outer.Content -Offset 0
    $fields = Read-Asn1Sequence -Content $seqTlv.Content

    $result = [PSCustomObject]@{
        Key       = $null
        Nonce     = $null
        Flags     = $null
        AuthTime  = $null
        StartTime = $null
        EndTime   = $null
        RenewTill = $null
        SRealm    = $null
        SName     = $null
    }

    foreach ($field in $fields) {
        $tagNumber = $field.Tag -band 0x1f
        $inner = Read-Asn1Tlv -Bytes $field.Content -Offset 0
        switch ($tagNumber) {
            0 {
                $keyFields = Read-Asn1Sequence -Content $inner.Content
                $keyType = $null
                $keyValue = $null
                foreach ($kf in $keyFields) {
                    $kfTag = $kf.Tag -band 0x1f
                    $kfInner = Read-Asn1Tlv -Bytes $kf.Content -Offset 0
                    if ($kfTag -eq 0) { $keyType = [int](ConvertFrom-Asn1Integer -Content $kfInner.Content) }
                    if ($kfTag -eq 1) { $keyValue = $kfInner.Content }
                }
                $result.Key = [PSCustomObject]@{ KeyType = $keyType; KeyValue = $keyValue }
            }
            2 { $result.Nonce = [uint32](ConvertFrom-Asn1Integer -Content $inner.Content) }
            4 {
                $bits = $inner.Content
                $flagBytes = if ($bits.Length -gt 1) { [byte[]]$bits[1..($bits.Length - 1)] } else { [byte[]]@() }
                if ($flagBytes.Length -lt 4) {
                    $padded = New-Object byte[] 4
                    [Array]::Copy($flagBytes, 0, $padded, 0, $flagBytes.Length)
                    $flagBytes = $padded
                }
                $be = [byte[]]$flagBytes[0..3]
                if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($be) }
                $result.Flags = [BitConverter]::ToUInt32($be, 0)
            }
            5 { $result.AuthTime = ConvertFrom-Asn1GeneralizedTime -Content $inner.Content }
            6 { $result.StartTime = ConvertFrom-Asn1GeneralizedTime -Content $inner.Content }
            7 { $result.EndTime = ConvertFrom-Asn1GeneralizedTime -Content $inner.Content }
            8 { $result.RenewTill = ConvertFrom-Asn1GeneralizedTime -Content $inner.Content }
            9 { $result.SRealm = ConvertFrom-Asn1GeneralString -Content $inner.Content }
            10 { $result.SName = ConvertFrom-KerberosPrincipalName -Content $inner.Content }
            default { }
        }
    }

    return $result
}

function ConvertFrom-KrbError {
    <#
        .SYNOPSIS
        Parses a KRB-ERROR ::= [APPLICATION 30] SEQUENCE (RFC 4120 5.9.1) into a
        PSCustomObject with the fields most useful for diagnostics.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$Message
    )

    $outer = Read-Asn1Tlv -Bytes $Message -Offset 0
    if ($outer.Tag -ne 0x7e) {
        throw ("ConvertFrom-KrbError: expected [APPLICATION 30] (tag 0x7E), got tag 0x{0:X2}." -f $outer.Tag)
    }
    $seqTlv = Read-Asn1Tlv -Bytes $outer.Content -Offset 0
    $fields = Read-Asn1Sequence -Content $seqTlv.Content

    $result = [PSCustomObject]@{
        ErrorCode = $null
        ErrorText = $null
        CRealm    = $null
        CName     = $null
        Realm     = $null
        SName     = $null
    }

    foreach ($field in $fields) {
        $tagNumber = $field.Tag -band 0x1f
        $inner = Read-Asn1Tlv -Bytes $field.Content -Offset 0
        switch ($tagNumber) {
            6 { $result.ErrorCode = ConvertFrom-Asn1Integer -Content $inner.Content }
            9 { $result.Realm = ConvertFrom-Asn1GeneralString -Content $inner.Content }
            11 { $result.ErrorText = ConvertFrom-Asn1GeneralString -Content $inner.Content }
            default { }
        }
    }

    return $result
}