Private/PSPkinit/KerberosCrypto.ps1

<#
    Kerberos cryptographic primitives per RFC 3961 (encryption/checksum framework) and
    RFC 3962 (AES enctypes). Implements only what PKINIT (RFC 4556) needs for the
    Diffie-Hellman key delivery method against aes128/aes256-cts-hmac-sha1-96.
 
    ConvertTo-NFold and Protect-/Unprotect-AesCbcCts are verified against the official
    test vectors published in RFC 3961 Appendix A.1 and RFC 3962 Appendix B respectively
    (see Tests/KerberosCrypto.Tests.ps1). Get-DK/Get-DR are compositions of those verified
    primitives per the RFC 3961 section 5.1 formula, but have no independent published
    test vector for the AES cipher (only DES3 vectors exist in RFC 3961 Appendix A.3) -
    correctness relies on the correctness of the underlying, independently-verified
    primitives plus faithful transcription of the formula.
#>


function XorBytes {
    param(
        [Parameter(Mandatory)] [byte[]]$A,
        [Parameter(Mandatory)] [byte[]]$B
    )

    $result = New-Object byte[] $A.Length
    for ($i = 0; $i -lt $A.Length; $i++) {
        $result[$i] = $A[$i] -bxor $B[$i]
    }
    return $result
}

function Get-Gcd {
    param([long]$A, [long]$B)
    while ($B -ne 0) {
        $t = $B
        $B = $A % $B
        $A = $t
    }
    return $A
}

function Invoke-AesBlockEncrypt {
    <#
        .SYNOPSIS
        Encrypts exactly one 16-byte block with a fresh ICryptoTransform, since
        TransformFinalBlock() must only be called once per transform instance -
        reusing one across multiple blocks silently produces wrong ciphertext.
    #>

    param(
        [Parameter(Mandatory)] [System.Security.Cryptography.Aes]$Aes,
        [Parameter(Mandatory)] [byte[]]$Block
    )

    $transform = $Aes.CreateEncryptor()
    try {
        return $transform.TransformFinalBlock($Block, 0, $Block.Length)
    } finally {
        $transform.Dispose()
    }
}

function Invoke-AesBlockDecrypt {
    <#
        .SYNOPSIS
        Decrypts exactly one 16-byte block with a fresh ICryptoTransform, since
        TransformFinalBlock() must only be called once per transform instance -
        reusing one across multiple blocks silently produces wrong plaintext.
    #>

    param(
        [Parameter(Mandatory)] [System.Security.Cryptography.Aes]$Aes,
        [Parameter(Mandatory)] [byte[]]$Block
    )

    $transform = $Aes.CreateDecryptor()
    try {
        return $transform.TransformFinalBlock($Block, 0, $Block.Length)
    } finally {
        $transform.Dispose()
    }
}

function ConvertTo-NFold {
    <#
        .SYNOPSIS
        Implements the "n-fold" algorithm from RFC 3961 section 5.1.
 
        .DESCRIPTION
        Stretches or shrinks an input octet string to a fixed-length output octet
        string, giving each input bit approximately equal weight in the output, per
        the algorithm attributed to Blumenthal/Bellovin as specified in RFC 3961.
 
        This is a direct, literal translation of the RFC's textual description
        (replicate the input to lcm(n, len(X)) bits, rotating right by 13 bits
        before each successive repetition, then sum the resulting n-bit chunks
        using one's-complement addition with end-around carry) rather than the
        bit-twiddling optimized reference C implementation, to keep the logic
        auditable against the spec text.
 
        .PARAMETER InputBytes
        The input octet string.
 
        .PARAMETER OutputByteLength
        The desired output length, in octets.
 
        .OUTPUTS
        System.Byte[]
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$InputBytes,
        [Parameter(Mandatory)] [int]$OutputByteLength
    )

    $inBytes = $InputBytes.Length
    $outBytes = $OutputByteLength

    if ($inBytes -eq $outBytes) {
        return [byte[]]$InputBytes.Clone()
    }

    $inBits = $inBytes * 8
    $outBits = $outBytes * 8
    $gcdBits = Get-Gcd -A $inBits -B $outBits
    $lcmBits = $inBits * $outBits / $gcdBits
    $repetitions = $lcmBits / $inBits

    # Build the replicated, cumulatively-rotated bit sequence (bigBits[0] = MSB).
    $bigBits = New-Object bool[] $lcmBits
    for ($rep = 0; $rep -lt $repetitions; $rep++) {
        $rotation = (13 * $rep) % $inBits
        for ($bit = 0; $bit -lt $inBits; $bit++) {
            $srcBit = (($bit - $rotation) % $inBits + $inBits) % $inBits
            $byteIdx = [int][Math]::Floor($srcBit / 8)
            $bitInByte = $srcBit % 8
            $bitValue = (($InputBytes[$byteIdx] -shr (7 - $bitInByte)) -band 1) -eq 1
            $bigBits[$rep * $inBits + $bit] = $bitValue
        }
    }

    # Split into outBits-sized chunks and sum with one's-complement (end-around-carry) addition.
    $numChunks = $lcmBits / $outBits
    $accumulator = New-Object byte[] $outBytes

    for ($chunk = 0; $chunk -lt $numChunks; $chunk++) {
        $chunkBytes = New-Object byte[] $outBytes
        for ($byteIdx = 0; $byteIdx -lt $outBytes; $byteIdx++) {
            $b = 0
            for ($bitIdx = 0; $bitIdx -lt 8; $bitIdx++) {
                $globalBit = $chunk * $outBits + $byteIdx * 8 + $bitIdx
                if ($bigBits[$globalBit]) {
                    $b = $b -bor (1 -shl (7 - $bitIdx))
                }
            }
            $chunkBytes[$byteIdx] = $b
        }

        [int]$carry = 0
        $newAcc = New-Object byte[] $outBytes
        for ($i = $outBytes - 1; $i -ge 0; $i--) {
            $sum = $accumulator[$i] + $chunkBytes[$i] + $carry
            $newAcc[$i] = $sum -band 0xff
            $carry = $sum -shr 8
        }
        while ($carry -gt 0) {
            $innerCarry = $carry
            $carry = 0
            for ($i = $outBytes - 1; $i -ge 0 -and $innerCarry -gt 0; $i--) {
                $sum = $newAcc[$i] + $innerCarry
                $newAcc[$i] = $sum -band 0xff
                $innerCarry = $sum -shr 8
            }
            $carry = $innerCarry
        }
        $accumulator = $newAcc
    }

    return $accumulator
}

function Protect-AesCbcCts {
    <#
        .SYNOPSIS
        AES-CBC encryption with ciphertext stealing (CTS), per RFC 3962 section 5.
 
        .OUTPUTS
        PSCustomObject with Ciphertext and NextIv byte[] properties.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$Key,
        [Parameter(Mandatory)] [byte[]]$InitializationVector,
        [Parameter(Mandatory)] [byte[]]$Plaintext
    )

    $blockSize = 16
    $aes = [System.Security.Cryptography.Aes]::Create()
    $aes.Key = $Key
    $aes.Mode = [System.Security.Cryptography.CipherMode]::ECB
    $aes.Padding = [System.Security.Cryptography.PaddingMode]::None

    try {
        $len = $Plaintext.Length

        if ($len -le $blockSize) {
            $padded = New-Object byte[] $blockSize
            [Array]::Copy($Plaintext, $padded, $len)
            $cipherBlock = Invoke-AesBlockEncrypt -Aes $aes -Block $padded
            return [PSCustomObject]@{
                Ciphertext = $cipherBlock[0..($len - 1)]
                NextIv     = $cipherBlock
            }
        }

        $blockCount = [int][Math]::Ceiling($len / [double]$blockSize)
        $d = $len - (($blockCount - 1) * $blockSize)

        $blocks = New-Object 'System.Collections.Generic.List[byte[]]'
        for ($b = 0; $b -lt $blockCount; $b++) {
            $chunk = New-Object byte[] $blockSize
            $start = $b * $blockSize
            $copyLen = [Math]::Min($blockSize, $len - $start)
            [Array]::Copy($Plaintext, $start, $chunk, 0, $copyLen)
            $blocks.Add($chunk)
        }

        $n = $blocks.Count
        $cipherBlocks = New-Object 'System.Collections.Generic.List[byte[]]'
        $prevCipher = $InitializationVector

        for ($b = 0; $b -lt ($n - 2); $b++) {
            $xored = XorBytes -A $blocks[$b] -B $prevCipher
            $c = Invoke-AesBlockEncrypt -Aes $aes -Block $xored
            $cipherBlocks.Add($c)
            $prevCipher = $c
        }

        $xSecondLast = XorBytes -A $blocks[$n - 2] -B $prevCipher
        $eSecondLast = Invoke-AesBlockEncrypt -Aes $aes -Block $xSecondLast

        $cLast = $eSecondLast[0..($d - 1)]

        # Dn = En-1 XOR P, where P is Pn zero-padded to a full block (RFC 2040 section 8, step 5).
        $dBlock = New-Object byte[] $blockSize
        for ($i = 0; $i -lt $d; $i++) {
            $dBlock[$i] = $eSecondLast[$i] -bxor $blocks[$n - 1][$i]
        }
        if ($d -lt $blockSize) {
            [Array]::Copy($eSecondLast, $d, $dBlock, $d, $blockSize - $d)
        }

        $cSecondLast = Invoke-AesBlockEncrypt -Aes $aes -Block $dBlock
        $cipherBlocks.Add($cSecondLast)
        $cipherBlocks.Add($cLast)

        $allBytes = [System.Collections.Generic.List[byte]]::new()
        foreach ($cb in $cipherBlocks) { $allBytes.AddRange($cb) }

        return [PSCustomObject]@{
            Ciphertext = $allBytes.ToArray()
            NextIv     = $cSecondLast
        }
    } finally {
        $aes.Dispose()
    }
}

function Unprotect-AesCbcCts {
    <#
        .SYNOPSIS
        AES-CBC decryption with ciphertext stealing (CTS), per RFC 3962 section 5.
 
        .OUTPUTS
        System.Byte[]
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$Key,
        [Parameter(Mandatory)] [byte[]]$InitializationVector,
        [Parameter(Mandatory)] [byte[]]$Ciphertext
    )

    $blockSize = 16
    $aes = [System.Security.Cryptography.Aes]::Create()
    $aes.Key = $Key
    $aes.Mode = [System.Security.Cryptography.CipherMode]::ECB
    $aes.Padding = [System.Security.Cryptography.PaddingMode]::None

    try {
        $len = $Ciphertext.Length

        if ($len -le $blockSize) {
            $padded = New-Object byte[] $blockSize
            [Array]::Copy($Ciphertext, $padded, $len)
            $plainBlock = Invoke-AesBlockDecrypt -Aes $aes -Block $padded
            return $plainBlock[0..($len - 1)]
        }

        $fullBlocks = [int][Math]::Floor($len / [double]$blockSize)
        $remainder = $len - ($fullBlocks * $blockSize)
        if ($remainder -eq 0) {
            $n = $fullBlocks
            $d = $blockSize
        } else {
            $n = $fullBlocks + 1
            $d = $remainder
        }

        $cBlocks = New-Object 'System.Collections.Generic.List[byte[]]'
        $pos = 0
        for ($b = 0; $b -lt ($n - 2); $b++) {
            $cBlocks.Add([byte[]]$Ciphertext[$pos..($pos + $blockSize - 1)])
            $pos += $blockSize
        }
        $cSecondLast = $Ciphertext[$pos..($pos + $blockSize - 1)]
        $pos += $blockSize
        $cLast = $Ciphertext[$pos..($pos + $d - 1)]

        $dBlock = Invoke-AesBlockDecrypt -Aes $aes -Block $cSecondLast

        # Pn = first Ln bytes of Xn, where Xn = Dn XOR C (Cn zero-padded) - so the leading
        # bytes must be XORed with the received Cn, not copied directly (RFC 2040 section 8).
        $pLastReal = New-Object byte[] $d
        for ($i = 0; $i -lt $d; $i++) {
            $pLastReal[$i] = $dBlock[$i] -bxor $cLast[$i]
        }

        $eSecondLast = New-Object byte[] $blockSize
        [Array]::Copy($cLast, 0, $eSecondLast, 0, $d)
        if ($d -lt $blockSize) {
            [Array]::Copy($dBlock, $d, $eSecondLast, $d, $blockSize - $d)
        }

        $xSecondLast = Invoke-AesBlockDecrypt -Aes $aes -Block $eSecondLast

        $prevCipher = if ($n -gt 2) { $cBlocks[$n - 3] } else { $InitializationVector }
        $pSecondLast = XorBytes -A $xSecondLast -B $prevCipher

        $plainBlocks = [System.Collections.Generic.List[byte]]::new()
        $prevC = $InitializationVector
        for ($b = 0; $b -lt ($n - 2); $b++) {
            $p = Invoke-AesBlockDecrypt -Aes $aes -Block $cBlocks[$b]
            $p = XorBytes -A $p -B $prevC
            $plainBlocks.AddRange([byte[]]$p)
            $prevC = $cBlocks[$b]
        }

        $plainBlocks.AddRange([byte[]]$pSecondLast)
        $plainBlocks.AddRange([byte[]]$pLastReal)

        return $plainBlocks.ToArray()
    } finally {
        $aes.Dispose()
    }
}

function Get-KerberosUsageConstant {
    <#
        .SYNOPSIS
        Builds the 5-octet "well-known constant" used by DK() for a given key usage
        number and derived-key type (Kc=0x99, Ke=0xAA, Ki=0x55), per RFC 3961 5.3.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [uint32]$KeyUsage,
        [Parameter(Mandatory)] [byte]$Suffix
    )

    $usageBytes = [BitConverter]::GetBytes($KeyUsage)
    if ([BitConverter]::IsLittleEndian) {
        [Array]::Reverse($usageBytes)
    }
    return $usageBytes + [byte[]]@($Suffix)
}

function Get-DR {
    <#
        .SYNOPSIS
        RFC 3961 section 5.1 DR() (derived-random) function, specialised for AES
        (cipher block size 16 octets, random-to-key = identity).
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$Key,
        [Parameter(Mandatory)] [byte[]]$Constant
    )

    $blockSize = 16
    $c = $Constant
    if ($c.Length -ne $blockSize) {
        $c = ConvertTo-NFold -InputBytes $c -OutputByteLength $blockSize
    }

    $needed = $Key.Length
    $aes = [System.Security.Cryptography.Aes]::Create()
    $aes.Key = $Key
    $aes.Mode = [System.Security.Cryptography.CipherMode]::ECB
    $aes.Padding = [System.Security.Cryptography.PaddingMode]::None

    try {
        $blocks = [System.Collections.Generic.List[byte]]::new()
        $prev = $c
        while ($blocks.Count -lt $needed) {
            # Explicit [byte[]] cast: a script function's `return $byteArray` can come back
            # through the pipeline as System.Object[] rather than byte[] (observed on Windows
            # PowerShell 5.1 / .NET Framework), and List[byte].AddRange(IEnumerable<byte>) is a
            # generic method call that does NOT get PowerShell's friendly Object[]->byte[] method
            # argument coercion, so it throws PSInvalidCastException without this cast.
            $block = [byte[]](Invoke-AesBlockEncrypt -Aes $aes -Block $prev)
            $blocks.AddRange($block)
            $prev = $block
        }
        return [byte[]]$blocks.ToArray()[0..($needed - 1)]
    } finally {
        $aes.Dispose()
    }
}

function Get-DK {
    <#
        .SYNOPSIS
        RFC 3961 section 5.1 DK() (derived-key) function, specialised for AES
        (random-to-key = identity, so DK == DR for this cipher).
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$BaseKey,
        [Parameter(Mandatory)] [byte[]]$Constant
    )

    return Get-DR -Key $BaseKey -Constant $Constant
}

function Get-KerberosDerivedKeys {
    <#
        .SYNOPSIS
        Derives the Kc/Ke/Ki triple used by the RFC 3961 simplified encryption/checksum
        profile for a given base key and key usage number.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [byte[]]$BaseKey,
        [Parameter(Mandatory)] [uint32]$KeyUsage
    )

    return [PSCustomObject]@{
        Kc = Get-DK -BaseKey $BaseKey -Constant (Get-KerberosUsageConstant -KeyUsage $KeyUsage -Suffix 0x99)
        Ke = Get-DK -BaseKey $BaseKey -Constant (Get-KerberosUsageConstant -KeyUsage $KeyUsage -Suffix 0xAA)
        Ki = Get-DK -BaseKey $BaseKey -Constant (Get-KerberosUsageConstant -KeyUsage $KeyUsage -Suffix 0x55)
    }
}

function Get-HmacSha1_96 {
    <#
        .SYNOPSIS
        HMAC-SHA1 truncated to the first 96 bits (12 octets), as used by
        aes128/256-cts-hmac-sha1-96 per RFC 3962.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$Key,
        [Parameter(Mandatory)] [byte[]]$Message
    )

    $hmac = [System.Security.Cryptography.HMACSHA1]::new($Key)
    try {
        $full = $hmac.ComputeHash($Message)
        return $full[0..11]
    } finally {
        $hmac.Dispose()
    }
}

function ConvertTo-OctetString2Key {
    <#
        .SYNOPSIS
        RFC 4556 section 3.2.3.1 octetstring2key() function, used to derive the
        PKINIT AS reply key from the Diffie-Hellman shared secret (and DH nonces,
        if DH key reuse is negotiated).
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$InputBytes,
        [Parameter(Mandatory)] [int]$KeyByteLength
    )

    $sha1 = [System.Security.Cryptography.SHA1]::Create()
    try {
        $output = [System.Collections.Generic.List[byte]]::new()
        $counter = 0
        while ($output.Count -lt $KeyByteLength) {
            $prefixed = [byte[]]@([byte]$counter) + $InputBytes
            $hash = $sha1.ComputeHash($prefixed)
            $output.AddRange($hash)
            $counter++
        }
        return $output.ToArray()[0..($KeyByteLength - 1)]
    } finally {
        $sha1.Dispose()
    }
}

function Protect-KerberosData {
    <#
        .SYNOPSIS
        RFC 3961 section 5.3 simplified-profile encryption: confounder + plaintext,
        AES-CBC-CTS encrypted under Ke, with an HMAC-SHA1-96 under Ki over the
        confounder+plaintext appended to the ciphertext.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$BaseKey,
        [Parameter(Mandatory)] [uint32]$KeyUsage,
        [Parameter(Mandatory)] [byte[]]$Plaintext
    )

    $keys = Get-KerberosDerivedKeys -BaseKey $BaseKey -KeyUsage $KeyUsage
    $confounder = New-Object byte[] 16
    $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
    try {
        $rng.GetBytes($confounder)
    } finally {
        $rng.Dispose()
    }

    $toEncrypt = $confounder + $Plaintext
    $iv = New-Object byte[] 16
    $encrypted = Protect-AesCbcCts -Key $keys.Ke -InitializationVector $iv -Plaintext $toEncrypt
    $mac = Get-HmacSha1_96 -Key $keys.Ki -Message $toEncrypt

    return $encrypted.Ciphertext + $mac
}

function Unprotect-KerberosData {
    <#
        .SYNOPSIS
        Reverses Protect-KerberosData: verifies the HMAC-SHA1-96 trailer under Ki,
        then AES-CBC-CTS decrypts under Ke and strips the 16-byte confounder.
    #>

    [CmdletBinding()]
    [OutputType([byte[]])]
    param(
        [Parameter(Mandatory)] [byte[]]$BaseKey,
        [Parameter(Mandatory)] [uint32]$KeyUsage,
        [Parameter(Mandatory)] [byte[]]$Ciphertext
    )

    $keys = Get-KerberosDerivedKeys -BaseKey $BaseKey -KeyUsage $KeyUsage
    $macLength = 12
    $cipherLength = $Ciphertext.Length - $macLength
    if ($cipherLength -le 0) {
        throw 'Ciphertext too short to contain an HMAC-SHA1-96 trailer.'
    }

    $cipherOnly = $Ciphertext[0..($cipherLength - 1)]
    $receivedMac = $Ciphertext[$cipherLength..($Ciphertext.Length - 1)]

    $iv = New-Object byte[] 16
    $decrypted = Unprotect-AesCbcCts -Key $keys.Ke -InitializationVector $iv -Ciphertext $cipherOnly

    $computedMac = Get-HmacSha1_96 -Key $keys.Ki -Message $decrypted
    $macsMatch = [System.Linq.Enumerable]::SequenceEqual([byte[]]$computedMac, [byte[]]$receivedMac)
    if (-not $macsMatch) {
        throw 'Kerberos EncryptedData integrity check failed (HMAC mismatch) - wrong key or corrupted data.'
    }

    return $decrypted[16..($decrypted.Length - 1)]
}