Private/PSPkinit/Asn1.ps1

<#
    Minimal DER (Distinguished Encoding Rules) ASN.1 encode/decode helpers, sufficient
    for the Kerberos (RFC 4120) and PKINIT (RFC 4556) structures used by this module.
 
    The Kerberos ASN.1 modules are declared "DEFINITIONS EXPLICIT TAGS", so unless a
    field is explicitly marked IMPLICIT in the module text, context tags wrap a
    complete inner TLV (tag+length+value) rather than replacing the universal tag.
#>


function Get-Asn1LengthBytes {
    <#
        .SYNOPSIS
        DER length-of-length encoding for a given content length.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [int]$Length
    )

    if ($Length -lt 0) {
        throw "Length cannot be negative: $Length"
    }

    if ($Length -lt 0x80) {
        return [byte[]]@([byte]$Length)
    }

    $bytes = [System.Collections.Generic.List[byte]]::new()
    $remaining = $Length
    while ($remaining -gt 0) {
        $bytes.Insert(0, [byte]($remaining -band 0xff))
        $remaining = $remaining -shr 8
    }
    return [byte[]]@([byte](0x80 -bor $bytes.Count)) + $bytes.ToArray()
}

function New-Asn1Tlv {
    <#
        .SYNOPSIS
        Builds a single DER TLV (tag, length, value) from a raw tag byte and content.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte]$Tag,
        [Parameter()] [byte[]]$Content = @()
    )

    $lengthBytes = Get-Asn1LengthBytes -Length $Content.Length
    return [byte[]]@($Tag) + $lengthBytes + $Content
}

function ConvertTo-Asn1Integer {
    <#
        .SYNOPSIS
        DER INTEGER encoding (tag 0x02) from a non-negative value, expressed as
        minimal big-endian two's-complement bytes (leading 0x00 added if the
        high bit of the most significant byte would otherwise be set).
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Int64')] [long]$Value,
        [Parameter(Mandatory, ParameterSetName = 'Bytes')] [byte[]]$Bytes
    )

    if ($PSCmdlet.ParameterSetName -eq 'Int64') {
        if ($Value -lt 0) {
            throw "ConvertTo-Asn1Integer only supports non-negative Int64 values; got $Value."
        }
        $Bytes = [System.Numerics.BigInteger]::new($Value).ToByteArray()
        [Array]::Reverse($Bytes)
        # BigInteger.ToByteArray() is little-endian and already includes a sign byte
        # when needed; after reversing to big-endian, strip any redundant leading
        # 0x00 bytes beyond the one needed to keep the value non-negative.
        while ($Bytes.Length -gt 1 -and $Bytes[0] -eq 0 -and $Bytes[1] -lt 0x80) {
            $Bytes = $Bytes[1..($Bytes.Length - 1)]
        }
    }

    if ($Bytes.Length -eq 0) {
        $Bytes = [byte[]]@(0)
    } elseif ($Bytes[0] -ge 0x80) {
        $Bytes = [byte[]]@(0) + $Bytes
    }

    return New-Asn1Tlv -Tag 0x02 -Content $Bytes
}

function ConvertTo-Asn1OctetString {
    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]]$Bytes
    )

    return New-Asn1Tlv -Tag 0x04 -Content $Bytes
}

function ConvertTo-Asn1BitString {
    <#
        .SYNOPSIS
        DER BIT STRING encoding (tag 0x03). Content is prefixed with a single
        "unused bits" octet (0 for byte-aligned data, which is all Kerberos/PKINIT
        uses this for: DH public values and signatures).
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$Bytes,
        [Parameter()] [byte]$UnusedBits = 0
    )

    return New-Asn1Tlv -Tag 0x03 -Content ([byte[]]@($UnusedBits) + $Bytes)
}

function ConvertTo-Asn1GeneralString {
    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [string]$Value
    )

    return New-Asn1Tlv -Tag 0x1B -Content ([System.Text.Encoding]::ASCII.GetBytes($Value))
}

function ConvertTo-Asn1GeneralizedTime {
    <#
        .SYNOPSIS
        KerberosTime encoding: GeneralizedTime with no fractional seconds, UTC ("Z").
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [datetime]$Value
    )

    $utc = $Value.ToUniversalTime()
    $text = $utc.ToString('yyyyMMddHHmmss') + 'Z'
    return New-Asn1Tlv -Tag 0x18 -Content ([System.Text.Encoding]::ASCII.GetBytes($text))
}

function ConvertTo-Asn1Oid {
    <#
        .SYNOPSIS
        DER OBJECT IDENTIFIER encoding (tag 0x06) from dotted-decimal notation.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [string]$Dotted
    )

    $arcs = $Dotted.Split('.') | ForEach-Object { [long]$_ }
    if ($arcs.Count -lt 2) {
        throw "OID must have at least two arcs: $Dotted"
    }

    $content = [System.Collections.Generic.List[byte]]::new()
    $content.Add([byte](($arcs[0] * 40) + $arcs[1]))

    for ($i = 2; $i -lt $arcs.Count; $i++) {
        $arc = [long]$arcs[$i]
        $arcBytes = [System.Collections.Generic.List[byte]]::new()
        $arcBytes.Add([byte]($arc -band 0x7f))
        $arc = $arc -shr 7
        while ($arc -gt 0) {
            $arcBytes.Insert(0, [byte](0x80 -bor ($arc -band 0x7f)))
            $arc = $arc -shr 7
        }
        $content.AddRange($arcBytes)
    }

    return New-Asn1Tlv -Tag 0x06 -Content $content.ToArray()
}

function ConvertTo-Asn1Sequence {
    <#
        .SYNOPSIS
        Wraps a set of already-encoded child TLVs in a DER SEQUENCE (tag 0x30).
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter()] [byte[][]]$Children = @()
    )

    $content = [System.Collections.Generic.List[byte]]::new()
    foreach ($child in $Children) {
        if ($null -ne $child -and $child.Length -gt 0) {
            $content.AddRange($child)
        }
    }
    return New-Asn1Tlv -Tag 0x30 -Content $content.ToArray()
}

function ConvertTo-Asn1ContextExplicit {
    <#
        .SYNOPSIS
        Wraps an already-encoded inner TLV in an EXPLICIT context-specific
        constructed tag ([N] under a DEFINITIONS EXPLICIT TAGS module).
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [ValidateRange(0, 30)] [int]$TagNumber,
        [Parameter(Mandatory)] [byte[]]$InnerTlv
    )

    return New-Asn1Tlv -Tag ([byte](0xA0 -bor $TagNumber)) -Content $InnerTlv
}

function ConvertTo-Asn1ContextImplicitPrimitive {
    <#
        .SYNOPSIS
        Encodes content under an IMPLICIT primitive context-specific tag ([N] IMPLICIT
        OCTET STRING, etc.), replacing the universal tag entirely rather than wrapping it.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [ValidateRange(0, 30)] [int]$TagNumber,
        [Parameter(Mandatory)] [AllowEmptyCollection()] [byte[]]$Content
    )

    return New-Asn1Tlv -Tag ([byte](0x80 -bor $TagNumber)) -Content $Content
}

function ConvertTo-Asn1ApplicationConstructed {
    <#
        .SYNOPSIS
        Wraps an already-encoded inner TLV (typically a SEQUENCE) in an
        [APPLICATION N] constructed tag, e.g. AS-REQ ::= [APPLICATION 10] KDC-REQ.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [ValidateRange(0, 30)] [int]$TagNumber,
        [Parameter(Mandatory)] [byte[]]$InnerTlv
    )

    return New-Asn1Tlv -Tag ([byte](0x60 -bor $TagNumber)) -Content $InnerTlv
}

function Read-Asn1Tlv {
    <#
        .SYNOPSIS
        Reads a single DER TLV from a byte array starting at Offset, returning the
        tag byte, the content bytes, and the offset immediately following the TLV.
        Supports single-byte tags (0-30) and both short- and long-form DER lengths,
        which is sufficient for every structure defined in RFC 4120 / RFC 4556.
 
        .OUTPUTS
        PSCustomObject with Tag ([byte]), Content ([byte[]]), and NextOffset ([int]).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$Bytes,
        [Parameter(Mandatory)] [int]$Offset
    )

    if ($Offset -ge $Bytes.Length) {
        throw "Read-Asn1Tlv: offset $Offset is at or past end of buffer (length $($Bytes.Length))."
    }

    $tag = $Bytes[$Offset]
    if (($tag -band 0x1f) -eq 0x1f) {
        throw 'Read-Asn1Tlv: multi-byte (high) tag numbers are not supported.'
    }
    $pos = $Offset + 1

    $lengthByte = $Bytes[$pos]
    $pos++
    if ($lengthByte -lt 0x80) {
        $length = [int]$lengthByte
    } else {
        $numLengthBytes = $lengthByte -band 0x7f
        if ($numLengthBytes -eq 0) {
            throw 'Read-Asn1Tlv: indefinite-length DER encodings are not supported.'
        }
        $length = 0
        for ($i = 0; $i -lt $numLengthBytes; $i++) {
            $length = ($length -shl 8) -bor $Bytes[$pos]
            $pos++
        }
    }

    if ($pos + $length -gt $Bytes.Length) {
        throw "Read-Asn1Tlv: declared length $length at offset $pos exceeds buffer length $($Bytes.Length)."
    }

    $content = if ($length -eq 0) { [byte[]]@() } else { $Bytes[$pos..($pos + $length - 1)] }

    return [PSCustomObject]@{
        Tag        = $tag
        Content    = $content
        NextOffset = $pos + $length
    }
}

function Read-Asn1Sequence {
    <#
        .SYNOPSIS
        Parses the content of a SEQUENCE (or SEQUENCE OF) TLV into a list of its
        immediate child TLVs.
 
        .OUTPUTS
        Array of the same PSCustomObject shape returned by Read-Asn1Tlv.
    #>

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

    $children = [System.Collections.Generic.List[object]]::new()
    $offset = 0
    while ($offset -lt $Content.Length) {
        $tlv = Read-Asn1Tlv -Bytes $Content -Offset $offset
        $children.Add($tlv)
        $offset = $tlv.NextOffset
    }
    return $children.ToArray()
}

function ConvertFrom-Asn1Integer {
    <#
        .SYNOPSIS
        Decodes DER INTEGER content bytes (big-endian two's complement) to a
        signed 64-bit integer.
    #>

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

    $be = [byte[]]$Content.Clone()
    [Array]::Reverse($be)
    $big = [System.Numerics.BigInteger]::new($be)
    return [long]$big
}

function ConvertFrom-Asn1GeneralizedTime {
    [CmdletBinding()]
    [OutputType([datetime])]
    param(
        [Parameter(Mandatory)] [byte[]]$Content
    )

    $text = [System.Text.Encoding]::ASCII.GetString($Content)
    return [datetime]::ParseExact($text, 'yyyyMMddHHmmss\Z', [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal)
}

function ConvertFrom-Asn1GeneralString {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)] [byte[]]$Content
    )

    return [System.Text.Encoding]::ASCII.GetString($Content)
}