Private/Crypto/Spake2Ed25519.ps1

$script:Ed25519Q = [System.Numerics.BigInteger]::Pow([System.Numerics.BigInteger]2, 255) - 19
$script:Ed25519L = [System.Numerics.BigInteger]::Pow([System.Numerics.BigInteger]2, 252) + [System.Numerics.BigInteger]::Parse('27742317777372353535851937790883648493')
$script:Ed25519I = [System.Numerics.BigInteger]::ModPow(2, ($script:Ed25519Q - 1) / 4, $script:Ed25519Q)
$script:Spake2SymmetricSeed = [System.Text.Encoding]::ASCII.GetBytes('symmetric')
$script:Ed25519Initialized = $false

function New-Ed25519Element {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Numerics.BigInteger] $X,

        [Parameter(Mandatory = $true)]
        [System.Numerics.BigInteger] $Y,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Unknown', 'Element', 'Zero')]
        [string] $Kind
    )

    $z = 1
    $t = ($X * $Y) % $script:Ed25519Q
    if ($t -lt 0) { $t += $script:Ed25519Q }

    [pscustomobject]@{
        PSTypeName = 'PowerWormhole.Ed25519Element'
        X = ($X % $script:Ed25519Q + $script:Ed25519Q) % $script:Ed25519Q
        Y = ($Y % $script:Ed25519Q + $script:Ed25519Q) % $script:Ed25519Q
        Z = [System.Numerics.BigInteger]$z
        T = $t
        Kind = $Kind
    }
}

function Invoke-Ed25519Inverse {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Numerics.BigInteger] $Value
    )

    $normalized = ($Value % $script:Ed25519Q + $script:Ed25519Q) % $script:Ed25519Q
    [System.Numerics.BigInteger]::ModPow($normalized, $script:Ed25519Q - 2, $script:Ed25519Q)
}

function Get-Ed25519XRecover {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Numerics.BigInteger] $Y
    )

    $yy = ($Y * $Y) % $script:Ed25519Q
    $numerator = ($yy - 1) % $script:Ed25519Q
    $denominator = ($script:Ed25519D * $yy + 1) % $script:Ed25519Q
    if ($numerator -lt 0) { $numerator += $script:Ed25519Q }
    if ($denominator -lt 0) { $denominator += $script:Ed25519Q }

    $xx = ($numerator * (Invoke-Ed25519Inverse $denominator)) % $script:Ed25519Q
    $x = [System.Numerics.BigInteger]::ModPow($xx, ($script:Ed25519Q + 3) / 8, $script:Ed25519Q)

    $check = (($x * $x) - $xx) % $script:Ed25519Q
    if ($check -lt 0) { $check += $script:Ed25519Q }
    if ($check -ne 0) {
        $x = ($x * $script:Ed25519I) % $script:Ed25519Q
    }

    if (($x % 2) -ne 0) {
        $x = $script:Ed25519Q - $x
    }

    ($x % $script:Ed25519Q + $script:Ed25519Q) % $script:Ed25519Q
}

function Test-Ed25519OnCurve {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Numerics.BigInteger] $X,

        [Parameter(Mandatory = $true)]
        [System.Numerics.BigInteger] $Y
    )

    $lhs = ((-$X * $X) + ($Y * $Y) - 1 - ($script:Ed25519D * $X * $X * $Y * $Y)) % $script:Ed25519Q
    if ($lhs -lt 0) { $lhs += $script:Ed25519Q }
    $lhs -eq 0
}

function Get-Ed25519AffineFromExtended {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Element
    )

    $zInv = Invoke-Ed25519Inverse $Element.Z
    $x = ($Element.X * $zInv) % $script:Ed25519Q
    $y = ($Element.Y * $zInv) % $script:Ed25519Q
    if ($x -lt 0) { $x += $script:Ed25519Q }
    if ($y -lt 0) { $y += $script:Ed25519Q }

    [pscustomobject]@{
        X = $x
        Y = $y
    }
}

function Invoke-Ed25519Double {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Element
    )

    $x1 = $Element.X
    $y1 = $Element.Y
    $z1 = $Element.Z

    $a = ($x1 * $x1) % $script:Ed25519Q
    $b = ($y1 * $y1) % $script:Ed25519Q
    $c = (2 * $z1 * $z1) % $script:Ed25519Q
    $d = (-$a) % $script:Ed25519Q
    $j = ($x1 + $y1) % $script:Ed25519Q
    $e = ($j * $j - $a - $b) % $script:Ed25519Q
    $g = ($d + $b) % $script:Ed25519Q
    $f = ($g - $c) % $script:Ed25519Q
    $h = ($d - $b) % $script:Ed25519Q
    $x3 = ($e * $f) % $script:Ed25519Q
    $y3 = ($g * $h) % $script:Ed25519Q
    $z3 = ($f * $g) % $script:Ed25519Q
    $t3 = ($e * $h) % $script:Ed25519Q

    if ($x3 -lt 0) { $x3 += $script:Ed25519Q }
    if ($y3 -lt 0) { $y3 += $script:Ed25519Q }
    if ($z3 -lt 0) { $z3 += $script:Ed25519Q }
    if ($t3 -lt 0) { $t3 += $script:Ed25519Q }

    [pscustomobject]@{
        PSTypeName = 'PowerWormhole.Ed25519Element'
        X = $x3
        Y = $y3
        Z = $z3
        T = $t3
        Kind = 'Unknown'
    }
}

function Invoke-Ed25519Add {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Left,

        [Parameter(Mandatory = $true)]
        [pscustomobject] $Right
    )

    if ($Left.Kind -eq 'Zero') { return $Right }
    if ($Right.Kind -eq 'Zero') { return $Left }

    $x1 = $Left.X
    $y1 = $Left.Y
    $z1 = $Left.Z
    $t1 = $Left.T

    $x2 = $Right.X
    $y2 = $Right.Y
    $z2 = $Right.Z
    $t2 = $Right.T

    $a = (($y1 - $x1) * ($y2 - $x2)) % $script:Ed25519Q
    $b = (($y1 + $x1) * ($y2 + $x2)) % $script:Ed25519Q
    $c = ($t1 * (2 * $script:Ed25519D) * $t2) % $script:Ed25519Q
    $d = ($z1 * 2 * $z2) % $script:Ed25519Q
    $e = ($b - $a) % $script:Ed25519Q
    $f = ($d - $c) % $script:Ed25519Q
    $g = ($d + $c) % $script:Ed25519Q
    $h = ($b + $a) % $script:Ed25519Q
    $x3 = ($e * $f) % $script:Ed25519Q
    $y3 = ($g * $h) % $script:Ed25519Q
    $z3 = ($f * $g) % $script:Ed25519Q
    $t3 = ($e * $h) % $script:Ed25519Q

    if ($x3 -lt 0) { $x3 += $script:Ed25519Q }
    if ($y3 -lt 0) { $y3 += $script:Ed25519Q }
    if ($z3 -lt 0) { $z3 += $script:Ed25519Q }
    if ($t3 -lt 0) { $t3 += $script:Ed25519Q }

    $candidate = [pscustomobject]@{
        PSTypeName = 'PowerWormhole.Ed25519Element'
        X = $x3
        Y = $y3
        Z = $z3
        T = $t3
        Kind = 'Unknown'
    }

    if (Test-Ed25519IsExtendedZero -Element $candidate) {
        return $script:Ed25519Zero
    }

    $candidate
}

function Invoke-Ed25519ScalarMultSafe {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Element,

        [Parameter(Mandatory = $true)]
        [System.Numerics.BigInteger] $Scalar
    )

    if ($Scalar -lt 0) {
        throw 'Safe scalar multiplication requires non-negative scalar.'
    }

    if ($Scalar -eq 0) {
        return $script:Ed25519Zero
    }

    $half = Invoke-Ed25519ScalarMultSafe -Element $Element -Scalar ($Scalar / 2)
    $doubled = Invoke-Ed25519Double -Element $half
    if (($Scalar % 2) -ne 0) {
        return Invoke-Ed25519Add -Left $doubled -Right $Element
    }

    $doubled
}

function Invoke-Ed25519ScalarMult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Element,

        [Parameter(Mandatory = $true)]
        [System.Numerics.BigInteger] $Scalar
    )

    if ($Element.Kind -eq 'Zero') {
        return $script:Ed25519Zero
    }

    if ($Element.Kind -eq 'Element') {
        $s = (($Scalar % $script:Ed25519L) + $script:Ed25519L) % $script:Ed25519L
        if ($s -eq 0) {
            return $script:Ed25519Zero
        }
        return ConvertTo-Ed25519Element -Element (Invoke-Ed25519ScalarMultSafe -Element $Element -Scalar $s)
    }

    if ($Scalar -lt 0) {
        throw 'Unknown-group scalar multiplication requires non-negative scalar.'
    }

    Invoke-Ed25519ScalarMultSafe -Element $Element -Scalar $Scalar
}

function ConvertTo-Ed25519PointBytes {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Element
    )

    $affine = Get-Ed25519AffineFromExtended -Element $Element
    $x = $affine.X
    $y = $affine.Y

    if (($x % 2) -ne 0) {
        $y = $y + [System.Numerics.BigInteger]::Pow([System.Numerics.BigInteger]2, 255)
    }

    ConvertTo-BigIntegerLittleEndianBytes -Value $y -Length 32
}

function ConvertFrom-Ed25519PointBytes {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [byte[]] $Bytes
    )

    if ($Bytes.Length -ne 32) {
        throw 'Ed25519 point encoding must be 32 bytes.'
    }

    if ((Compare-ByteArrays -Left $Bytes -Right $script:Ed25519ZeroBytes)) {
        return $script:Ed25519Zero
    }

    $unclamped = ConvertFrom-BigIntegerLittleEndianBytes -Bytes $Bytes
    $clamp = [System.Numerics.BigInteger]::Pow([System.Numerics.BigInteger]2, 255) - 1
    $y = $unclamped -band $clamp
    $x = Get-Ed25519XRecover -Y $y
    $signBit = ($unclamped -band ([System.Numerics.BigInteger]::Pow([System.Numerics.BigInteger]2, 255))) -ne 0
    if ((($x -band 1) -ne 0) -ne $signBit) {
        $x = $script:Ed25519Q - $x
    }

    if (-not (Test-Ed25519OnCurve -X $x -Y $y)) {
        throw 'Decoded point is not on Ed25519 curve.'
    }

    New-Ed25519Element -X $x -Y $y -Kind 'Unknown'
}

function ConvertTo-Ed25519Element {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Element
    )

    if ($Element.Kind -eq 'Zero') {
        throw 'Element was Zero.'
    }

    $scaled = Invoke-Ed25519ScalarMultSafe -Element $Element -Scalar $script:Ed25519L
    if (-not (Test-Ed25519IsExtendedZero -Element $scaled)) {
        throw 'Element is not in the right subgroup.'
    }

    [pscustomobject]@{
        PSTypeName = 'PowerWormhole.Ed25519Element'
        X = $Element.X
        Y = $Element.Y
        Z = $Element.Z
        T = $Element.T
        Kind = 'Element'
    }
}

function Test-Ed25519IsExtendedZero {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Element
    )

    $x = (($Element.X % $script:Ed25519Q) + $script:Ed25519Q) % $script:Ed25519Q
    $y = (($Element.Y % $script:Ed25519Q) + $script:Ed25519Q) % $script:Ed25519Q
    $z = (($Element.Z % $script:Ed25519Q) + $script:Ed25519Q) % $script:Ed25519Q

    ($x -eq 0) -and ($y -eq $z) -and ($y -ne 0)
}

function ConvertTo-BigIntegerLittleEndianBytes {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Numerics.BigInteger] $Value,

        [Parameter(Mandatory = $true)]
        [int] $Length
    )

    $normalized = ($Value % [System.Numerics.BigInteger]::Pow([System.Numerics.BigInteger]2, 8 * $Length) + [System.Numerics.BigInteger]::Pow([System.Numerics.BigInteger]2, 8 * $Length)) % [System.Numerics.BigInteger]::Pow([System.Numerics.BigInteger]2, 8 * $Length)
    $buffer = [byte[]]::new($Length)
    $working = $normalized

    for ($index = 0; $index -lt $Length; $index += 1) {
        $buffer[$index] = [byte]($working -band 0xff)
        $working = $working / 256
    }

    $buffer
}

function ConvertFrom-BigIntegerLittleEndianBytes {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [byte[]] $Bytes
    )

    $value = [System.Numerics.BigInteger]::Zero
    for ($index = $Bytes.Length - 1; $index -ge 0; $index -= 1) {
        $value = ($value * 256) + $Bytes[$index]
    }

    $value
}

function ConvertFrom-BigIntegerBigEndianBytes {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [byte[]] $Bytes
    )

    $value = [System.Numerics.BigInteger]::Zero
    for ($index = 0; $index -lt $Bytes.Length; $index += 1) {
        $value = ($value * 256) + $Bytes[$index]
    }

    $value
}

function Compare-ByteArrays {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [byte[]] $Left,

        [Parameter(Mandatory = $true)]
        [byte[]] $Right
    )

    if ($Left.Length -ne $Right.Length) {
        return $false
    }

    $diff = 0
    for ($index = 0; $index -lt $Left.Length; $index += 1) {
        $diff = $diff -bor ($Left[$index] -bxor $Right[$index])
    }

    $diff -eq 0
}

function Get-Spake2PasswordScalar {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [byte[]] $Password
    )

    $expanded = Invoke-WormholeHkdfSha256 -InputKeyMaterial $Password -Length 48 -Salt ([byte[]]::new(0)) -Info ([System.Text.Encoding]::ASCII.GetBytes('SPAKE2 pw'))
    $i = ConvertFrom-BigIntegerBigEndianBytes -Bytes $expanded
    $i % $script:Ed25519L
}

function Get-Spake2ArbitraryElement {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [byte[]] $Seed
    )

    $expanded = Invoke-WormholeHkdfSha256 -InputKeyMaterial $Seed -Length 48 -Salt ([byte[]]::new(0)) -Info ([System.Text.Encoding]::ASCII.GetBytes('SPAKE2 arbitrary element'))
    $y = (ConvertFrom-BigIntegerBigEndianBytes -Bytes $expanded) % $script:Ed25519Q
    $plus = [System.Numerics.BigInteger]::Zero

    while ($true) {
        $yPlus = ($y + $plus) % $script:Ed25519Q
        $x = Get-Ed25519XRecover -Y $yPlus
        if (-not (Test-Ed25519OnCurve -X $x -Y $yPlus)) {
            $plus += 1
            continue
        }

        $candidate = New-Ed25519Element -X $x -Y $yPlus -Kind 'Unknown'
        $p8 = Invoke-Ed25519ScalarMultSafe -Element $candidate -Scalar 8
        if (Test-Ed25519IsExtendedZero -Element $p8) {
            $plus += 1
            continue
        }

        $check = Invoke-Ed25519ScalarMultSafe -Element $p8 -Scalar $script:Ed25519L
        if (-not (Test-Ed25519IsExtendedZero -Element $check)) {
            $plus += 1
            continue
        }

        return ConvertTo-Ed25519Element -Element $p8
    }
}

function New-Spake2SymmetricContext {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [byte[]] $Password,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [byte[]] $IdSymmetric,

        [Parameter(Mandatory = $true)]
        [byte[]] $RandomBytes
    )

    if ($RandomBytes.Length -lt 64) {
        throw 'SPAKE2 requires at least 64 random bytes for scalar generation.'
    }

    $oversized = [byte[]]::new(64)
    [Array]::Copy($RandomBytes, 0, $oversized, 0, 64)
    $xyScalar = (ConvertFrom-BigIntegerBigEndianBytes -Bytes $oversized) % $script:Ed25519L
    $pwScalar = Get-Spake2PasswordScalar -Password $Password
    $sElement = Get-Spake2ArbitraryElement -Seed $script:Spake2SymmetricSeed

    $xyElem = Invoke-Ed25519ScalarMult -Element $script:Ed25519Base -Scalar $xyScalar
    $pwBlinding = Invoke-Ed25519ScalarMult -Element $sElement -Scalar $pwScalar
    $messageElem = Invoke-Ed25519Add -Left $xyElem -Right $pwBlinding
    $outboundMessage = ConvertTo-Ed25519PointBytes -Element $messageElem

    $wireMessage = [byte[]]::new(33)
    $wireMessage[0] = 0x53
    [Array]::Copy($outboundMessage, 0, $wireMessage, 1, 32)

    [pscustomobject]@{
        PSTypeName = 'PowerWormhole.Spake2SymmetricContext'
        Password = $Password
        IdSymmetric = $IdSymmetric
        PasswordScalar = $pwScalar
        XYScalar = $xyScalar
        SElement = $sElement
        OutboundMessage = $outboundMessage
        Message = $wireMessage
    }
}

function Complete-Spake2Symmetric {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Context,

        [Parameter(Mandatory = $true)]
        [byte[]] $InboundSideAndMessage
    )

    if ($InboundSideAndMessage.Length -ne 33) {
        throw 'Inbound SPAKE2 message must be 33 bytes.'
    }

    $otherSide = $InboundSideAndMessage[0]
    if ($otherSide -ne 0x53) {
        throw 'Inbound SPAKE2 message has invalid side marker.'
    }

    $inboundMessage = [byte[]]::new(32)
    [Array]::Copy($InboundSideAndMessage, 1, $inboundMessage, 0, 32)

    $inboundElem = ConvertTo-Ed25519Element -Element (ConvertFrom-Ed25519PointBytes -Bytes $inboundMessage)
    if (Compare-ByteArrays -Left (ConvertTo-Ed25519PointBytes -Element $inboundElem) -Right $Context.OutboundMessage) {
        throw 'SPAKE2 reflection detected.'
    }

    $pwUnblinding = Invoke-Ed25519ScalarMult -Element $Context.SElement -Scalar (-$Context.PasswordScalar)
    $kElem = Invoke-Ed25519ScalarMult -Element (Invoke-Ed25519Add -Left $inboundElem -Right $pwUnblinding) -Scalar $Context.XYScalar
    $kBytes = ConvertTo-Ed25519PointBytes -Element $kElem

    $first = $inboundMessage
    $second = $Context.OutboundMessage
    $firstHex = ConvertTo-WormholeHex -Bytes $first
    $secondHex = ConvertTo-WormholeHex -Bytes $second
    if ([string]::CompareOrdinal($firstHex, $secondHex) -gt 0) {
        $tmp = $first
        $first = $second
        $second = $tmp
    }

    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $transcript = [System.Collections.Generic.List[byte]]::new()
        [byte[]]$hPw = $sha.ComputeHash([byte[]]$Context.Password)
        [byte[]]$hId = $sha.ComputeHash([byte[]]$Context.IdSymmetric)
        [byte[]]$firstBytes = $first
        [byte[]]$secondBytes = $second
        [byte[]]$kMaterial = $kBytes
        $transcript.AddRange($hPw)
        $transcript.AddRange($hId)
        $transcript.AddRange($firstBytes)
        $transcript.AddRange($secondBytes)
        $transcript.AddRange($kMaterial)
        $sharedKey = $sha.ComputeHash($transcript.ToArray())
    }
    finally {
        $sha.Dispose()
    }

    [pscustomobject]@{
        PSTypeName = 'PowerWormhole.Spake2Result'
        SharedKey = $sharedKey
        InboundMessage = $inboundMessage
        OutboundMessage = $Context.OutboundMessage
    }
}

function Initialize-Ed25519Statics {
    [CmdletBinding()]
    param()

    if ($script:Ed25519Initialized) {
        return
    }

    $script:Ed25519D = ((-121665) * (Invoke-Ed25519Inverse 121666)) % $script:Ed25519Q
    if ($script:Ed25519D -lt 0) { $script:Ed25519D += $script:Ed25519Q }
    $script:Ed25519By = (4 * (Invoke-Ed25519Inverse 5)) % $script:Ed25519Q
    $script:Ed25519Bx = Get-Ed25519XRecover -Y $script:Ed25519By
    $script:Ed25519Base = New-Ed25519Element -X $script:Ed25519Bx -Y $script:Ed25519By -Kind 'Element'
    $script:Ed25519Zero = New-Ed25519Element -X 0 -Y 1 -Kind 'Zero'
    $script:Ed25519ZeroBytes = ConvertTo-Ed25519PointBytes -Element $script:Ed25519Zero
    $script:Ed25519Initialized = $true
}

Initialize-Ed25519Statics