TotalCommanderFtpPasswordRecovery.psm1

################################################################################
#
# Total Commander FTP password decoder (wcx_ftp.ini stored form).
# Offline port of the reverse-engineered algorithm. Checksum validation
# present in Total Commander is not implemented here — invalid or tampered
# ciphertext may still yield a string result.
#
# Version : PowerShell SDK v1.0.0
# PowerShell : Windows PowerShell 5.1 / PowerShell 7+
# Author : Bartosz Wójcik (support@pelock.com)
# Homepage : https://www.pelock.com
#
################################################################################

Set-StrictMode -Version Latest

class TotalCommanderPasswordDecoder {
    hidden [uint32] $_randomSeed = 0

    static [byte[]] HexStringToByteArray([string]$Str) {
        $lowered = $Str.ToLowerInvariant()
        $len = $lowered.Length

        if ($len -eq 0 -or ($len -band 1) -ne 0) {
            return $null
        }

        $out = New-Object byte[] ($len / 2)

        for ($i = 0; $i -lt $len; $i += 2) {
            $hi = [TotalCommanderPasswordDecoder]::HexNibble([int][char]$lowered[$i])
            $lo = [TotalCommanderPasswordDecoder]::HexNibble([int][char]$lowered[$i + 1])
            if ($hi -lt 0 -or $lo -lt 0) {
                return $null
            }
            $out[$i / 2] = [byte]((($hi -shl 4) -bor $lo) -band 0xFF)
        }

        return $out
    }

    static [int] HexNibble([int]$Code) {
        if ($Code -ge 0x30 -and $Code -le 0x39) {
            return $Code - 0x30
        }
        if ($Code -ge 0x61 -and $Code -le 0x66) {
            return $Code - 0x61 + 10
        }
        return -1
    }

    [void] SeedPrng([uint32]$Seed) {
        $this._randomSeed = $Seed
    }

    hidden [uint32] NextRandMax([uint32]$NMax) {
        $modulus = [bigint]::Pow(2, 32)
        $nextSeed = (([bigint]$this._randomSeed * [bigint]0x8088405 + [bigint]1) % $modulus)
        $this._randomSeed = [uint32]$nextSeed
        $shifted = (([bigint]$this._randomSeed * [bigint]$NMax) -shr 32)
        return [uint32]$shifted
    }

    static [byte] Rol8([int]$Value, [int]$Counter) {
        $b = $Value -band 0xFF
        $c = $Counter -band 7
        if ($c -eq 0) {
            return [byte]$b
        }
        return [byte]((($b -shl $c) -bor ($b -shr (8 - $c))) -band 0xFF)
    }

    [byte[]] DecryptPassword([string]$PasswordHex) {
        $normalized = [regex]::Replace($PasswordHex, '\s+', '')
        $bytes = [TotalCommanderPasswordDecoder]::HexStringToByteArray($normalized)
        if ($null -eq $bytes) {
            return $null
        }

        $passwordLength = $bytes.Length
        if ($passwordLength -le 4) {
            return $null
        }

        $passwordLength -= 4
        $buf = New-Object byte[] $bytes.Length
        [Buffer]::BlockCopy($bytes, 0, $buf, 0, $bytes.Length)

        $this.SeedPrng(849521)
        for ($i = 0; $i -lt $passwordLength; $i++) {
            $buf[$i] = [TotalCommanderPasswordDecoder]::Rol8([int]$buf[$i], [int]$this.NextRandMax(8))
        }

        $this.SeedPrng(12345)
        for ($i = 0; $i -lt 256; $i++) {
            $x = [int]$this.NextRandMax([uint32]$passwordLength)
            $y = [int]$this.NextRandMax([uint32]$passwordLength)
            $t = $buf[$x]
            $buf[$x] = $buf[$y]
            $buf[$y] = $t
        }

        $this.SeedPrng(42340)
        for ($i = 0; $i -lt $passwordLength; $i++) {
            $buf[$i] = [byte](($buf[$i] -bxor [byte]$this.NextRandMax(256)) -band 0xFF)
        }

        $this.SeedPrng(54321)
        for ($i = 0; $i -lt $passwordLength; $i++) {
            $buf[$i] = [byte]((([int]$buf[$i] - [int]$this.NextRandMax(256)) -band 0xFF))
        }

        $plain = New-Object byte[] $passwordLength
        [Buffer]::BlockCopy($buf, 0, $plain, 0, $passwordLength)
        return $plain
    }

}

function New-TotalCommanderPasswordDecoder {
    <#
    .SYNOPSIS
        Creates an offline Total Commander FTP password decoder.

    .DESCRIPTION
        Factory for TotalCommanderPasswordDecoder. Decrypts hex passwords stored
        in wcx_ftp.ini. No HTTP calls are made.

    .EXAMPLE
        $decoder = New-TotalCommanderPasswordDecoder
        $bytes = $decoder.DecryptPassword('00112233445566778899aabbccddeeff')
    #>

    [CmdletBinding()]
    [OutputType([TotalCommanderPasswordDecoder])]
    param()

    return [TotalCommanderPasswordDecoder]::new()
}

Export-ModuleMember -Function @('New-TotalCommanderPasswordDecoder')