ShaCrypt.psm1

function Convert-ByteArrayToMappedBase64 ($ByteArray, $OrderMap,$CharMap)
{
    ($OrderMap | ForEach-Object -Process {
        $ba = @($_ | ForEach-Object -Process {$ByteArray[$_]})
        $ba += @(0) * (4 - $ba.Length)
        $int = [System.BitConverter]::ToUInt32($ba, 0)
        0..($_.Length) | ForEach-Object -Process {
            $CharMap[$int -band 63]
            $int = $int -shr 6
        }
    }) -join ''
}

function Get-ShaCryptHashRaw ($Hasher, $Password, $Salt, $Rounds)
{
    function Hex ($ByteArray)
    {
        ($ByteArray | ForEach-Object -Process {$_.ToString("x2")}) -join ''
    }

    function Repeat ([array] $Array, [uint16] $Count)
    {
        $Array * [System.Math]::Ceiling($Count / $Array.Length) | Select-Object -First $Count
    }

    $password_len = $Password.Length
    $salt_len     = $Salt.Length

    # Digest B
    [byte[]] $b_ctx = $Password + $Salt + $Password
    #Write-Host -Object "Digest B ctx: $(Hex $b_ctx)"
    [byte[]] $db = $Hasher.ComputeHash($b_ctx)
    #Write-Host -Object "Digest B : $(Hex $db)"

    # Digest A
    [byte[]] $a_ctx = $Password + $Salt + (Repeat $db $password_len)
    $i = $password_len
    while ($i)
    {
        $a_ctx += if ($i -band 1) {$db} else {$Password}
        $i = $i -shr 1
    }
    #Write-Host -Object "Digest A ctx: $(Hex $a_ctx)"
    [byte[]] $da = $Hasher.ComputeHash($a_ctx)
    #Write-Host -Object "Digest A : $(Hex $da)"

    # Digest P
    [byte[]] $p_ctx = $Password * $password_len
    #Write-Host -Object "Digest P ctx: $(Hex $p_ctx)"
    [byte[]] $dp = $Hasher.ComputeHash($p_ctx)
    #Write-Host -Object "Digest P tmp: $(Hex $dp)"
    $dp = Repeat $dp $password_len
    #Write-Host -Object "Digest P : $(Hex $dp)"

    # Digest S
    [byte[]] $s_ctx = $Salt * (16 + $da[0])
    #Write-Host -Object "Digest S ctx: $(Hex $s_ctx)"
    [byte[]] $ds = $Hasher.ComputeHash($s_ctx)
    #Write-Host -Object "Digest S tmp: $(Hex $ds)"
    $ds = Repeat $ds $salt_len
    #Write-Host -Object "Digest S : $(Hex $ds)"

    # Loop
    [byte[]] $dc = $da
    for ($i = 0; $i -lt $Rounds ; $i++)
    {
        [byte[]] $c_ctx = if ($i % 2) {$dp} else {$dc}
        if ($i % 3) {$c_ctx += $ds}
        if ($i % 7) {$c_ctx += $dp}
        $c_ctx += if ($i % 2) {$dc} else {$dp}
        $dc = $Hasher.ComputeHash($c_ctx)
    }
    #Write-Host -Object "Digest C : $(Hex $dc)"

    # Return
    $dc
}

# Override Write-Verbose in this module so calling function is added to the message
function script:Write-Verbose
{
    [CmdletBinding()]
    param
    (
       [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] [String] $Message
    )

    begin
    {}

    process
    {
        try
        {
            $PSBoundParameters['Message'] = $((Get-PSCallStack)[1].Command) + ': ' + $PSBoundParameters['Message']
        }
        catch
        {}

        Microsoft.PowerShell.Utility\Write-Verbose @PSBoundParameters
    }

    end
    {}
}

function New-ShaPassword
{
    <#
        .SYNOPSIS
            Create SHA password hash for use in Linux /etc/shadow file

        .DESCRIPTION
            Hash the password with either sha256crypt or sha512crypt

        .PARAMETER Password
            Must be of type [String], [SecureString], [PSCredential] or [Byte[]]
            Must be at least one character long
 
        .PARAMETER Salt
            Must be either [String] or [Byte[]]
            Must be 8 to 16 characters long
 
        .PARAMETER Rounds
            Rounds
 
        .PARAMETER Sha512
            SHA-512 (sha512crypt), this is the default
 
        .PARAMETER Sha256
            SHA-256 (sha256crypt)
 
        .PARAMETER OutputAll
            Normal output. Output the string to use in /etc/password
 
        .PARAMETER OutputHashOnly
            Just output the hashed password without salt, rounds, version and dollar signs

        .EXAMPLE
            $hash = New-ShaPassword -Password Password1
    #>


    [CmdletBinding(DefaultParameterSetName='Sha512All')]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [object]
        $Password,

        [Parameter()]
        [PSObject]
        $Salt = $null,

        [Parameter()]
        [uint32]
        $Rounds,

        [Parameter(ParameterSetName = 'Sha512All')]
        [Parameter(ParameterSetName = 'Sha512HashOnly')]
        [switch]
        $Sha512,

        [Parameter(ParameterSetName = 'Sha256All',      Mandatory = $true)]
        [Parameter(ParameterSetName = 'Sha256HashOnly', Mandatory = $true)]
        [switch]
        $Sha256,

        [Parameter(ParameterSetName = 'Sha256All')]
        [Parameter(ParameterSetName = 'Sha512All')]
        [switch]
        $OutputAll,

        [Parameter(ParameterSetName = 'Sha256HashOnly', Mandatory = $true)]
        [Parameter(ParameterSetName = 'Sha512HashOnly', Mandatory = $true)]
        [switch]
        $OutputHashOnly
    )

    begin
    {
        Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)"
        $origErrorActionPreference = $ErrorActionPreference
        $origErrorActionPreferenceGlobal = $global:ErrorActionPreference

        # ValidateScript process each element of an array individually, so we put it here instead
        try
        {
            if (
                ($Salt -ne $null -or $Salt -is [array]) -and  # For some reason both @()-eq$null-and$true and @()-ne$null-and$true returns $False!!!!
                (
                    ($Salt -is [array] -and (($sTmp = [byte[]] $Salt) -or $true)) -or
                    (($sTmp = [string] $Salt) -or $true)
                ) -and
                ($sTmp.Length -lt 8 -or $sTmp.Length -gt 16)
            )
            {
                throw
            }
        }
        catch
        {
            throw 'Salt must be string or byte array with length between 8 and 16'
        }

        $defaultRounds = 5000
        $charMapSha = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.ToCharArray()
        $orderMapSha256 = @(
            @(20, 10,  0),
            @(11,  1, 21),
            @( 2, 22, 12),
            @(23, 13,  3),
            @(14,  4, 24),
            @( 5, 25, 15),
            @(26, 16,  6),
            @(17,  7, 27),
            @( 8, 28, 18),
            @(29, 19,  9),
            @(30, 31)
        )
        $orderMapSha512 = @(
            @(42, 21,  0),
            @( 1, 43, 22),
            @(23,  2, 44),
            @(45, 24,  3),
            @( 4, 46, 25),
            @(26,  5, 47),
            @(48, 27,  6),
            @( 7, 49, 28),
            @(29,  8, 50),
            @(51, 30,  9),
            @(10, 52, 31),
            @(32, 11, 53),
            @(54, 33, 12),
            @(13, 55, 34),
            @(35, 14, 56),
            @(57, 36, 15),
            @(16, 58, 37),
            @(38, 17, 59),
            @(60, 39, 18),
            @(19, 61, 40),
            @(41, 20, 62),
            @(63)
        )

        if (-not $Rounds)
        {
            $Rounds = $defaultRounds
        }
        elseif ($Rounds -lt 1000)
        {
            'Rounds is {0} (<1000), changing it to 1000' -f $Rounds | Write-Verbose
            $Rounds = 1000
        }
        elseif ($Rounds -gt 999999999)
        {
            'Rounds is {0} (<999999999), changing it to 999999999' -f $Rounds | Write-Verbose
            $Rounds = 999999999
        }
    }

    process
    {
        Write-Verbose -Message "Process begin (ErrorActionPreference: $ErrorActionPreference)"

        try
        {
            # Stop and catch all errors. Local ErrorAction isn't propagate when calling functions in other modules
            $global:ErrorActionPreference = $ErrorActionPreference = 'Stop'

            # Non-boilerplate stuff starts here

            [byte[]] $passwordA = @()
            if ($Password -is [PSCredential])
            {
                $passwordA = [System.Text.Encoding]::UTF8.GetBytes([string] ($Password.GetNetworkCredential().Password))
            }
            elseif ($Password -is [SecureString])
            {
                $passwordA = [System.Text.Encoding]::UTF8.GetBytes([string] ([pscredential]::new('username', $Password).GetNetworkCredential().Password))
            }
            elseif ($Password -is [array])
            {
                try
                {
                    $passwordA = $Password
                }
                catch
                {
                    throw 'Password must be of type [String], [SecureString], [PSCredential] or [Byte[]]'
                }
            }
            else
            {
                $passwordA = [System.Text.Encoding]::UTF8.GetBytes([string] $Password)
            }

            if (-not $passwordA.Length)
            {
                throw 'Length of password must be at least one character'
            }

            [byte[]] $saltA = @()
            if (-not $Salt)
            {
                # This is not Cryptography Secure Randomness!!
                $saltA = $charMapSha | Get-Random -Count 16
            }
            elseif ($Salt -is [array])
            {
                $saltA = $Salt
            }
            else
            {
                $saltA = [System.Text.Encoding]::UTF8.GetBytes([string] $Salt)
            }

            if ($Sha256)
            {
                $hasher   = [System.Security.Cryptography.SHA256CryptoServiceProvider]::new()
                $orderMap = $orderMapSha256
                $prefix   = '5'
            }
            else
            {
                $hasher   = [System.Security.Cryptography.SHA512CryptoServiceProvider]::new()
                $orderMap = $orderMapSha512
                $prefix   = '6'
            }

            $hashByteArray = Get-ShaCryptHashRaw -Hasher $hasher -Password $passwordA -Salt $saltA -Rounds $Rounds
            $hashString = Convert-ByteArrayToMappedBase64 -ByteArray $hashByteArray -OrderMap $orderMap -CharMap $charMapSha

            if ($OutputHashOnly)
            {
                $hashString
            }
            else
            {
                $roundsString = if ($Rounds -eq $defaultRounds) {''} else {'$rounds=' + $Rounds}
                $saltString = ([char[]] $saltA) -join ''
                '$' + $prefix + $roundsString + '$' + $saltString + '$' + $hashString
            }

            # Non-boilerplate stuff ends here
        }
        catch
        {
            # If error was encountered inside this function then stop processing
            # But still respect the ErrorAction that comes when calling this function
            # And also return the line number where the original error occured
            $msg = $_.ToString() + "`r`n" + $_.InvocationInfo.PositionMessage.ToString()
            Write-Verbose -Message "Encountered an error: $msg"
            Write-Error -ErrorAction $origErrorActionPreference -Exception $_.Exception -Message $msg
        }
        finally
        {
            # Clean up ErrorAction
            $global:ErrorActionPreference = $origErrorActionPreferenceGlobal
        }

        Write-Verbose -Message 'Process end'
    }

    end
    {
        Write-Verbose -Message 'End'
    }
}

function Test-ShaPassword
{
    <#
        .SYNOPSIS
            Test password hash from /etc/shadow agains a password
            Returns $true if password matches hash, otherwise $false

        .DESCRIPTION
            Only works with SHA-256 and SHA-512 hashes
            Just returns $false for other types of hashes (eg. MD5 and blowfish)

        .PARAMETER Password
            Must be of type [String], [SecureString], [PSCredential] or [Byte[]]

        .PARAMETER Hash
            Hash in format found in /etc/shadow

        .EXAMPLE
            Test-ShaPassword -Password Password1 -Hash '$5$UcRBt/.Teh4lvkLA$0xIrONKLF2exeQlUSKiYo8FieagtIzrovD8Ld19FB.4'
    #>


    [CmdletBinding()]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [object]
        $Password,

        [Parameter(Mandatory = $true)]
        [AllowEmptyString()]
        [System.String]
        $Hash
    )

    Write-Verbose -Message "Begin (ErrorActionPreference: $ErrorActionPreference)"
    $origErrorActionPreference = $ErrorActionPreference
    $origErrorActionPreferenceGlobal = $global:ErrorActionPreference

    try
    {
        # Stop and catch all errors. Local ErrorAction isn't propagate when calling functions in other modules
        $global:ErrorActionPreference = $ErrorActionPreference = 'Stop'

        # Non-boilerplate stuff starts here

        if (
            $Hash -cmatch '^\$(5)(\$rounds=(\d+))?\$([^\$]{8,16})\$([a-zA-Z0-9\.\/]{43})$' -or
            $Hash -cmatch '^\$(6)(\$rounds=(\d+))?\$([^\$]{8,16})\$([a-zA-Z0-9\.\/]{86})$'
        )
        {
            $type = if ($Matches[1] -eq 6) {'Sha512'} else {'Sha256'}
            "Hash is $type" | Write-Verbose

            $params = @{
                "$type"        = $true
                Password       = $Password
                Salt           = $Matches[4]
                OutputHashOnly = $true
            }
            if ($Matches[3]) {$params['Rounds'] = $Matches[3]}
            $expect = $Matches[5]

            # Return
            (New-ShaPassword @params) -ceq $expect
        }
        else
        {
            'Unknown hash type' | Write-Verbose

            # Return
            $false
        }

        # Non-boilerplate stuff ends here
    }
    catch
    {
        # If error was encountered inside this function then stop processing
        # But still respect the ErrorAction that comes when calling this function
        # And also return the line number where the original error occured
        $msg = $_.ToString() + "`r`n" + $_.InvocationInfo.PositionMessage.ToString()
        Write-Verbose -Message "Encountered an error: $msg"

        # Return in case of exception thrown
        $false
    }
    finally
    {
        # Clean up ErrorAction
        $global:ErrorActionPreference = $origErrorActionPreferenceGlobal
    }

    Write-Verbose -Message 'End'
}

Export-ModuleMember -Function New-ShaPassword
Export-ModuleMember -Function Test-ShaPassword