Private/Protocol/WormholeClientProtocol.ps1

function Initialize-WormholePake {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Session
    )

    Write-WormholeDebug -Component 'protocol' -Message 'Initializing SPAKE2 context.' -Session $Session -Data @{ appId = $Session.AppId }
    Start-WormholeSpake2 -Code $Session.Code -AppId $Session.AppId
}

function Invoke-WormholeStatus {
    [CmdletBinding()]
    param(
        [Parameter()]
        [scriptblock] $StatusCallback,

        [Parameter(Mandatory = $true)]
        [string] $Message
    )

    if ($null -ne $StatusCallback) {
        & $StatusCallback $Message
    }
}

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

        [Parameter(Mandatory = $true)]
        [string] $Text,

        [Parameter()]
        [int] $TimeoutSeconds = 300,

        [Parameter()]
        [scriptblock] $StatusCallback
    )

    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Starting key exchange...'
    Write-WormholeDebug -Component 'protocol' -Message 'Text send protocol started.' -Session $Session -Data @{ timeoutSeconds = $TimeoutSeconds; textLength = $Text.Length }
    $pakeContext = Initialize-WormholePake -Session $Session

    $pakeEnvelope = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ pake_v1 = (ConvertTo-WormholeHex -Bytes $pakeContext.Message) } -Depth 5 -Compress))
    Add-WormholeMailboxPayload -Session $Session -Phase 'pake' -Body $pakeEnvelope
    Write-WormholeDebug -Component 'protocol' -Message 'Sent PAKE payload.' -Session $Session -Data @{ bytes = $pakeEnvelope.Length }
    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Waiting for peer to join and exchange PAKE...'

    $peerPake = Receive-WormholeMailboxPayload -Session $Session -Phase 'pake' -TimeoutSeconds $TimeoutSeconds
    Write-WormholeDebug -Component 'protocol' -Message 'Received peer PAKE payload.' -Session $Session -Data @{ side = $peerPake.Side; bytes = $peerPake.Body.Length }
    $peerPakeJson = [System.Text.Encoding]::UTF8.GetString($peerPake.Body)
    $peerPakeObject = ConvertFrom-Json -InputObject $peerPakeJson
    $peerPakeBytes = ConvertFrom-WormholeHex -Hex ([string]$peerPakeObject.pake_v1)
    $result = Complete-WormholeSpake2 -Context $pakeContext -PeerMessage $peerPakeBytes
    Write-WormholeDebug -Component 'protocol' -Message 'Completed SPAKE2 and derived shared key.' -Session $Session -Data @{ sharedKeyLength = $result.SharedKey.Length }

    $versionPayload = @{
        app_versions = @{ 'PowerWormhole' = '0.1.0' }
    }
    $versionBytes = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $versionPayload -Depth 10 -Compress))
    $versionKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $Session.Side -Phase 'version'
    $cipherVersion = Protect-WormholeSecretBox -Key $versionKey -Plaintext $versionBytes
    Add-WormholeMailboxPayload -Session $Session -Phase 'version' -Body $cipherVersion
    Write-WormholeDebug -Component 'protocol' -Message 'Sent encrypted version payload.' -Session $Session -Data @{ plainBytes = $versionBytes.Length; cipherBytes = $cipherVersion.Length }
    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'PAKE complete. Verifying peer version...'

    $peerVersion = Receive-WormholeMailboxPayload -Session $Session -Phase 'version' -TimeoutSeconds $TimeoutSeconds
    Write-WormholeDebug -Component 'protocol' -Message 'Received encrypted peer version payload.' -Session $Session -Data @{ side = $peerVersion.Side; bytes = $peerVersion.Body.Length }
    $peerVersionKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $peerVersion.Side -Phase 'version'
    [void](Unprotect-WormholeSecretBox -Key $peerVersionKey -Ciphertext $peerVersion.Body)
    Write-WormholeDebug -Component 'protocol' -Message 'Peer version decrypted successfully.' -Session $Session

    $phase = [string]$Session.NextPhase
    $plaintext = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ offer = @{ message = $Text } } -Depth 10 -Compress))
    $phaseKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $Session.Side -Phase $phase
    $cipher = Protect-WormholeSecretBox -Key $phaseKey -Plaintext $plaintext
    Add-WormholeMailboxPayload -Session $Session -Phase $phase -Body $cipher
    Write-WormholeDebug -Component 'protocol' -Message 'Sent encrypted offer payload.' -Session $Session -Data @{ phase = $phase; plainBytes = $plaintext.Length; cipherBytes = $cipher.Length }

    $Session.NextPhase += 1
    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Offer sent. Waiting for receiver acknowledgement...'

    while ($true) {
        $incoming = Receive-WormholeMailboxPayload -Session $Session -TimeoutSeconds $TimeoutSeconds
        Write-WormholeDebug -Component 'protocol' -Message 'Received post-offer response candidate.' -Session $Session -Data @{ phase = $incoming.Phase; side = $incoming.Side; bytes = $incoming.Body.Length }
        if ($incoming.Phase -notmatch '^\d+$') {
            Write-WormholeDebug -Component 'protocol' -Message 'Skipping non-numeric post-offer message.' -Session $Session -Data @{ phase = $incoming.Phase }
            continue
        }

        $incomingKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $incoming.Side -Phase $incoming.Phase
        $incomingPlainBytes = Unprotect-WormholeSecretBox -Key $incomingKey -Ciphertext $incoming.Body
        $incomingText = [System.Text.Encoding]::UTF8.GetString($incomingPlainBytes)
        $incomingObject = ConvertFrom-Json -InputObject $incomingText

        if ($null -ne $incomingObject.PSObject.Properties['error'] -and $null -ne $incomingObject.error) {
            throw "Peer reported transfer error: $($incomingObject.error)"
        }

        $answerProp = $incomingObject.PSObject.Properties['answer']
        if ($null -ne $answerProp -and $null -ne $answerProp.Value -and
            $null -ne $answerProp.Value.PSObject.Properties['message_ack'] -and
            [string]$answerProp.Value.message_ack -eq 'ok') {
            Write-WormholeDebug -Component 'protocol' -Message 'Received message acknowledgement from receiver.' -Session $Session -Data @{ phase = $incoming.Phase }
            break
        }

        Write-WormholeDebug -Component 'protocol' -Message 'Post-offer response did not contain message acknowledgement.' -Session $Session -Data @{ phase = $incoming.Phase }
    }

    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Text message sent.'
    Write-WormholeDebug -Component 'protocol' -Message 'Text send protocol complete.' -Session $Session
}

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

        [Parameter()]
        [int] $TimeoutSeconds = 300,

        [Parameter()]
        [scriptblock] $StatusCallback
    )

    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Starting key exchange...'
    Write-WormholeDebug -Component 'protocol' -Message 'Text receive protocol started.' -Session $Session -Data @{ timeoutSeconds = $TimeoutSeconds }
    $pakeContext = Initialize-WormholePake -Session $Session

    $pakeEnvelope = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ pake_v1 = (ConvertTo-WormholeHex -Bytes $pakeContext.Message) } -Depth 5 -Compress))
    Add-WormholeMailboxPayload -Session $Session -Phase 'pake' -Body $pakeEnvelope
    Write-WormholeDebug -Component 'protocol' -Message 'Sent PAKE payload.' -Session $Session -Data @{ bytes = $pakeEnvelope.Length }
    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Waiting for peer PAKE message...'
    $peerPake = Receive-WormholeMailboxPayload -Session $Session -Phase 'pake' -TimeoutSeconds $TimeoutSeconds
    Write-WormholeDebug -Component 'protocol' -Message 'Received peer PAKE payload.' -Session $Session -Data @{ side = $peerPake.Side; bytes = $peerPake.Body.Length }
    $peerPakeJson = [System.Text.Encoding]::UTF8.GetString($peerPake.Body)
    $peerPakeObject = ConvertFrom-Json -InputObject $peerPakeJson
    $peerPakeBytes = ConvertFrom-WormholeHex -Hex ([string]$peerPakeObject.pake_v1)
    $result = Complete-WormholeSpake2 -Context $pakeContext -PeerMessage $peerPakeBytes
    Write-WormholeDebug -Component 'protocol' -Message 'Completed SPAKE2 and derived shared key.' -Session $Session -Data @{ sharedKeyLength = $result.SharedKey.Length }

    $versionPayload = @{
        app_versions = @{ 'PowerWormhole' = '0.1.0' }
    }
    $versionBytes = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $versionPayload -Depth 10 -Compress))
    $versionKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $Session.Side -Phase 'version'
    $cipherVersion = Protect-WormholeSecretBox -Key $versionKey -Plaintext $versionBytes
    Add-WormholeMailboxPayload -Session $Session -Phase 'version' -Body $cipherVersion
    Write-WormholeDebug -Component 'protocol' -Message 'Sent encrypted version payload.' -Session $Session -Data @{ plainBytes = $versionBytes.Length; cipherBytes = $cipherVersion.Length }

    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'PAKE complete. Verifying peer version...'
    $peerVersion = Receive-WormholeMailboxPayload -Session $Session -Phase 'version' -TimeoutSeconds $TimeoutSeconds
    Write-WormholeDebug -Component 'protocol' -Message 'Received encrypted peer version payload.' -Session $Session -Data @{ side = $peerVersion.Side; bytes = $peerVersion.Body.Length }
    $peerVersionKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $peerVersion.Side -Phase 'version'
    [void](Unprotect-WormholeSecretBox -Key $peerVersionKey -Ciphertext $peerVersion.Body)
    Write-WormholeDebug -Component 'protocol' -Message 'Peer version decrypted successfully.' -Session $Session
    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Waiting for inbound text message...'

    while ($true) {
        $incoming = Receive-WormholeMailboxPayload -Session $Session -TimeoutSeconds $TimeoutSeconds
        Write-WormholeDebug -Component 'protocol' -Message 'Received application-phase candidate message.' -Session $Session -Data @{ phase = $incoming.Phase; side = $incoming.Side; bytes = $incoming.Body.Length }
        if ($incoming.Phase -notmatch '^\d+$') {
            Write-WormholeDebug -Component 'protocol' -Message 'Skipping non-numeric phase message.' -Session $Session -Data @{ phase = $incoming.Phase }
            continue
        }

        $incomingKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $incoming.Side -Phase $incoming.Phase
        $plainBytes = Unprotect-WormholeSecretBox -Key $incomingKey -Ciphertext $incoming.Body
        $text = [System.Text.Encoding]::UTF8.GetString($plainBytes)
        Write-WormholeDebug -Component 'protocol' -Message 'Decrypted application payload.' -Session $Session -Data @{ phase = $incoming.Phase; plainBytes = $plainBytes.Length }
        $obj = ConvertFrom-Json -InputObject $text

        if ($null -ne $obj.PSObject.Properties['error'] -and $null -ne $obj.error) {
            throw "Peer reported transfer error: $($obj.error)"
        }

        $offerProp = $obj.PSObject.Properties['offer']
        if ($null -ne $offerProp -and $null -ne $offerProp.Value -and
            $null -ne $offerProp.Value.PSObject.Properties['message'] -and
            $null -ne $offerProp.Value.message) {
            $messageText = [string]$offerProp.Value.message
            Write-WormholeDebug -Component 'protocol' -Message 'Offer.message extracted successfully.' -Session $Session -Data @{ messageLength = $messageText.Length }

            $answerPhase = [string]$Session.NextPhase
            $answerPayload = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ answer = @{ message_ack = 'ok' } } -Depth 10 -Compress))
            $answerKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $Session.Side -Phase $answerPhase
            $answerCipher = Protect-WormholeSecretBox -Key $answerKey -Plaintext $answerPayload
            Add-WormholeMailboxPayload -Session $Session -Phase $answerPhase -Body $answerCipher
            $Session.NextPhase += 1
            Write-WormholeDebug -Component 'protocol' -Message 'Sent message acknowledgement to sender.' -Session $Session -Data @{ phase = $answerPhase }

            Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Text message received.'
            return $messageText
        }

        Write-WormholeDebug -Component 'protocol' -Message 'Application payload did not contain offer.message; continuing wait.' -Session $Session
    }
}

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

        [Parameter()]
        [string] $OutputDirectory = (Get-Location).Path,

        [Parameter()]
        [int] $TimeoutSeconds = 300,

        [Parameter()]
        [scriptblock] $StatusCallback
    )

    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Starting key exchange...'
    Write-WormholeDebug -Component 'protocol' -Message 'Unified receive protocol started.' -Session $Session -Data @{ timeoutSeconds = $TimeoutSeconds; outputDirectory = $OutputDirectory }

    $pakeContext = Initialize-WormholePake -Session $Session
    $pakeEnvelope = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ pake_v1 = (ConvertTo-WormholeHex -Bytes $pakeContext.Message) } -Depth 5 -Compress))
    Add-WormholeMailboxPayload -Session $Session -Phase 'pake' -Body $pakeEnvelope

    $peerPake = Receive-WormholeMailboxPayload -Session $Session -Phase 'pake' -TimeoutSeconds $TimeoutSeconds
    $peerPakeObject = ConvertFrom-Json -InputObject ([System.Text.Encoding]::UTF8.GetString($peerPake.Body))
    $peerPakeBytes = ConvertFrom-WormholeHex -Hex ([string]$peerPakeObject.pake_v1)
    $result = Complete-WormholeSpake2 -Context $pakeContext -PeerMessage $peerPakeBytes

    $versionPayload = @{ app_versions = @{ 'PowerWormhole' = '0.1.0' } }
    $versionBytes = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject $versionPayload -Depth 10 -Compress))
    $versionKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $Session.Side -Phase 'version'
    $cipherVersion = Protect-WormholeSecretBox -Key $versionKey -Plaintext $versionBytes
    Add-WormholeMailboxPayload -Session $Session -Phase 'version' -Body $cipherVersion

    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'PAKE complete. Verifying peer version...'
    $peerVersion = Receive-WormholeMailboxPayload -Session $Session -Phase 'version' -TimeoutSeconds $TimeoutSeconds
    $peerVersionKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $peerVersion.Side -Phase 'version'
    [void](Unprotect-WormholeSecretBox -Key $peerVersionKey -Ciphertext $peerVersion.Body)

    $transitKey = Get-WormholeTransitKey -SharedKey $result.SharedKey -AppId $Session.AppId
    $senderRecordKey   = Get-WormholeTransitRecordKey -TransitKey $transitKey -Direction 'sender'
    $receiverRecordKey = Get-WormholeTransitRecordKey -TransitKey $transitKey -Direction 'receiver'

    $peerTransitReceived = $false
    $peerRelayAddress = $script:PowerWormholeDefaults.TransitRelay
    $fileOfferReceived = $false
    $fileName = $null
    [long]$fileSize = 0

    Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Waiting for inbound offer...'
    $waitDeadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds)

    while ([DateTimeOffset]::UtcNow -lt $waitDeadline) {
        $remaining = [int][Math]::Ceiling(($waitDeadline - [DateTimeOffset]::UtcNow).TotalSeconds)
        $incoming = Receive-WormholeMailboxPayload -Session $Session -TimeoutSeconds $remaining

        if ($incoming.Phase -notmatch '^\d+$') {
            continue
        }

        $incomingKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $incoming.Side -Phase $incoming.Phase
        $plainBytes = Unprotect-WormholeSecretBox -Key $incomingKey -Ciphertext $incoming.Body
        $obj = ConvertFrom-Json -InputObject ([System.Text.Encoding]::UTF8.GetString($plainBytes))

        if ($null -ne $obj.PSObject.Properties['error'] -and $null -ne $obj.error) {
            throw "Peer reported transfer error: $($obj.error)"
        }

        if (-not $peerTransitReceived -and $null -ne $obj.PSObject.Properties['transit']) {
            $peerTransitReceived = $true
            $peerRelayAddress = Get-WormholeTransitRelayFromHints -TransitInfo $obj.transit

            $myTransitInfo = Build-WormholeTransitInfo
            $transitPhase = [string]$Session.NextPhase
            $transitPlain = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ transit = $myTransitInfo } -Depth 10 -Compress))
            $transitKeyPhase = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $Session.Side -Phase $transitPhase
            Add-WormholeMailboxPayload -Session $Session -Phase $transitPhase -Body (Protect-WormholeSecretBox -Key $transitKeyPhase -Plaintext $transitPlain)
            $Session.NextPhase += 1

            if (-not $fileOfferReceived) {
                continue
            }
        }

        $offerProp = $obj.PSObject.Properties['offer']
        if ($null -eq $offerProp -or $null -eq $offerProp.Value) {
            continue
        }

        if ($null -ne $offerProp.Value.PSObject.Properties['message'] -and $null -ne $offerProp.Value.message) {
            $messageText = [string]$offerProp.Value.message
            $answerPhase = [string]$Session.NextPhase
            $answerPayload = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ answer = @{ message_ack = 'ok' } } -Depth 10 -Compress))
            $answerKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $Session.Side -Phase $answerPhase
            $answerCipher = Protect-WormholeSecretBox -Key $answerKey -Plaintext $answerPayload
            Add-WormholeMailboxPayload -Session $Session -Phase $answerPhase -Body $answerCipher
            $Session.NextPhase += 1

            Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Text message received.'
            return [pscustomobject]@{
                Type = 'text'
                Text = $messageText
                FilePath = $null
                FileName = $null
                FileSize = $null
            }
        }

        if ($null -ne $offerProp.Value.PSObject.Properties['file'] -and $null -ne $offerProp.Value.file) {
            $fileOfferReceived = $true
            $fileName = [string]$offerProp.Value.file.filename
            $fileSize = [long]$offerProp.Value.file.filesize

            if (-not $peerTransitReceived) {
                continue
            }

            $answerPhase = [string]$Session.NextPhase
            $answerPlain = [System.Text.Encoding]::UTF8.GetBytes((ConvertTo-Json -InputObject @{ answer = @{ file_ack = 'ok' } } -Depth 10 -Compress))
            $answerPhaseKey = Get-WormholeDerivedPhaseKey -SharedKey $result.SharedKey -Side $Session.Side -Phase $answerPhase
            Add-WormholeMailboxPayload -Session $Session -Phase $answerPhase -Body (Protect-WormholeSecretBox -Key $answerPhaseKey -Plaintext $answerPlain)
            $Session.NextPhase += 1

            $outputPath = Join-Path -Path $OutputDirectory -ChildPath $fileName
            Invoke-WormholeStatus -StatusCallback $StatusCallback -Message "Receiving file: $fileName ($fileSize bytes)..."
            Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Connecting to transit relay...'

            $transitConn = Connect-WormholeTransitRelay -TransitRelay $peerRelayAddress -TransitKey $transitKey -Side $Session.Side -Role 'receiver' -TimeoutSeconds 60
            try {
                Invoke-WormholeStatus -StatusCallback $StatusCallback -Message 'Connected. Receiving file...'
                Receive-WormholeTransitFile -Transit $transitConn `
                    -SenderKey $senderRecordKey `
                    -ReceiverKey $receiverRecordKey `
                    -OutputPath $outputPath `
                    -ExpectedSize $fileSize `
                    -TimeoutSeconds $TimeoutSeconds `
                    -StatusCallback $StatusCallback
            }
            finally {
                try { $transitConn.Stream.Dispose() } catch { }
                try { $transitConn.TcpClient.Dispose() } catch { }
            }

            return [pscustomobject]@{
                Type = 'file'
                Text = $null
                FilePath = $outputPath
                FileName = $fileName
                FileSize = $fileSize
            }
        }
    }

    throw 'Timed out waiting for sender offer.'
}