YCPasswordReset.psm1

Set-Variable -Name KeyLength -Option Constant -Value 2048

function New-RSAPrivateKey {
    return [System.Security.Cryptography.RSACng]::New($KeyLength)
}

Set-Variable -Name Public -Option Constant -Value $true

function Export-Modulus {
    param(
        [Parameter(Mandatory=$true)]
        [System.Security.Cryptography.RSACng]
        [ValidateNotNull()]
        $RSAPrivateKey
    )

    $PrivateKeyParameters = $RSAPrivateKey.ExportParameters($Public)
    $Modulus = $PrivateKeyParameters.Modulus
    $Base64Modulus = [System.Convert]::ToBase64String($Modulus)

    return $Base64Modulus
}

function Export-Exponent {
    param(
        [Parameter(Mandatory=$true)]
        [System.Security.Cryptography.RSACng]
        [ValidateNotNull()]
        $RSAPrivateKey
    )

    $PrivateKeyParameters = $RSAPrivateKey.ExportParameters($Public)
    $Exponent = $PrivateKeyParameters.Exponent
    $Base64Exponent = [System.Convert]::ToBase64String($Exponent)

    return $Base64Exponent
}

function Get-Unixtime { return ConvertTo-Unixtime -Date (Get-Date) }

function ConvertTo-Unixtime {
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [datetime]$Date
    )

    $Epoch = [int64]([double]::Parse((Get-Date -Date ($Date.ToUniversalTime()) -UFormat "%s"),[CultureInfo][System.Threading.Thread]::CurrentThread.CurrentCulture))

    return $Epoch
}

function ConvertFrom-EncryptedString {
    param(
        [Parameter(Mandatory=$true)]
        [System.Security.Cryptography.RSACng]
        [ValidateNotNull()]
        $RSAPrivateKey,

        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [String]$EncryptedString
    )

    $EncryptedBytes = [System.Convert]::FromBase64String($EncryptedString)
    $DecryptedBytes = $RSAPrivateKey.Decrypt($EncryptedBytes, [System.Security.Cryptography.RSAEncryptionPadding]::OaepSHA256)
    $DecryptedString = [System.Text.Encoding]::UTF8.GetString($DecryptedBytes)

    return $DecryptedString
}

Set-Variable -Name Schema -Option Constant -Value "v1"

function New-PasswordResetRequest
{
    param(
        [Parameter(Mandatory = $true)]
        [System.Security.Cryptography.RSACng]
        [ValidateNotNull()]
        $RSAPrivateKey,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [String]$Username = "Administrator"
    )

    return [ordered]@{
        Username = $Username
        Modulus  = Export-Modulus -RSAPrivateKey $RSAPrivateKey
        Exponent = Export-Exponent -RSAPrivateKey $RSAPrivateKey
        Expires  = Get-Unixtime
        Schema   = $Schema
    }
}

Set-Variable -Name PasswordResetMessageType -Option Constant -Value "UserChangeRequest"

function New-Message {
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNull()]
        [PSObject]$Payload
    )

    return [ordered]@{
        Timestamp = Get-Unixtime
        ID        = [GUID]::Newguid().GUID.ToUpper()
        Type      = $PasswordResetMessageType
        Payload   = $Payload
    }
}

Set-Variable -Name ErrNoCli -Option Constant -Value "configured yc cli required to run this cmdlet"

function Test-CliInstalled {
    if (Get-CliVersion) {
        return $true
    }

    return $false
}

function Get-CliVersion {
    $Command = "& yc version"
    $Version = Invoke-Expression $Command
    if ($VerbosePreference) {
        "yc cli version detected: {0}" -f $Version | Write-Verbose
    }

    return $Version
}

function Test-Envelope {
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [PSObject]$Obj
    )

    if ($DebugPreference) {
        "Test it is valid envelope with payload: {0}" -f $Obj | Write-Verbose
    }

    $Members = $Obj | Get-Member
    $Properties = $Members | Where-Object MemberType -eq NoteProperty
    $Fields = $Properties | Select-Object -ExpandProperty Name

    foreach ($Field in @("Timestamp", "ID", "Type", "Payload")) {
        if ($Fields -notcontains $Field) {
            return $false
        }
    }

    return $true
}

function Test-UserChangeResponse {
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [PSObject]$Obj
    )

    if ($DebugPreference) {
        "Test it is valid user change response: {0}" -f $Obj | Write-Verbose
    }

    $Members = $Obj | Get-Member
    $Properties = $Members | Where-Object MemberType -eq NoteProperty
    $Fields = $Properties | Select-Object -ExpandProperty Name

    foreach ($Field in @("Modulus", "Exponent", "Username", "EncryptedPassword", "Success", "Error")) {
        if ($Fields -notcontains $Field) {
            return $false
        }
    }

    return $true
}

Set-Variable -Name Timeout -Option Constant -Value 60

Set-Variable -Name COMPort -Option Constant -Value 4

Set-Variable -Name PasswordResetResponseType -Option Constant -Value "UserChangeResponse"

function Reset-YCUserPassword {
    param(
        [Parameter(Mandatory = $true,
            ParameterSetName = 'Name',
            HelpMessage = 'Enter compute instance name')]
        [ValidateNotNullOrEmpty()]
        [String]$InstanceName,

        [Parameter(Mandatory = $true,
            ParameterSetName = 'ID',
            HelpMessage = 'Enter compute instance ID')]
        [ValidateNotNullOrEmpty()]
        [String]$InstanceID,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [String]$Username = "Administrator"
    )

    $isCli = Test-CliInstalled
    if (-not $isCli) {
        throw "yc cli is not installed or not in PATH, you must properly install it to proceed"
    }

    $PrivateKey = New-RSAPrivateKey
    $PasswordResetRequest = New-PasswordResetRequest -RSAPrivateKey $PrivateKey -Username $Username
    $Message = New-Message -Payload $PasswordResetRequest
    $RequestWrappedInMessage = $Message | ConvertTo-Json
    $TempFilePath = New-TemporaryFile | Select-Object -ExpandProperty FullName

    if ($VerbosePreference) {
        "Write request`n{0}`nIn temporary file: {1}" -f $RequestWrappedInMessage, $TempFilePath | Write-Verbose
    }
    Set-Content -Path $TempFilePath -Value $RequestWrappedInMessage -ErrorAction:Stop

    $Instance = "--name {0}" -f $InstanceName
    if ($PSCmdlet.ParameterSetName -eq 'ID') {
        $Instance = "--id {0}" -f $InstanceID
    }

    $Command = "& yc compute instance update {0} --metadata-from-file windows-users={1} --format json --no-user-output" -f $Instance, $TempFilePath
    if ($VerbosePreference) {
        "Invoke command: '{0}'" -f $Command | Write-Verbose
    }
    try {
        Invoke-Expression $Command -ErrorAction:Stop | Out-Null
    } catch {
        throw $Error
    } finally {
        Remove-Item -Path $TempFilePath -Confirm:$false
    }

    $StopWatchTimer = New-Object -TypeName System.Diagnostics.Stopwatch
    $StopWatchTimer.Start()

    do {
        $Command = "& yc compute instance get-serial-port-output {0} --port {1} --no-user-output" -f $Instance, $COMPort
        if ($VerbosePreference) {
            "Invoke command: '{0}'" -f $Command | Write-Verbose
        }
        [System.Array]$Output = Invoke-Expression $Command -ErrorAction:Stop

        foreach ($Line in $Output) {
            if ($DebugPreference) {
                "Processing line: {0}" -f $Line | Write-Verbose
            }
            if ([String]::IsNullOrEmpty($line)) {
                continue
            }

            try {
                $Envelope = $Line | ConvertFrom-Json -ErrorAction:SilentlyContinue
            } catch {
                continue
            }

            $isEnvelope = Test-Envelope $Envelope -ErrorAction:SilentlyContinue
            if (-not $isEnvelope) {
                continue
            }

            $isCorrectID = $Envelope.ID -eq $Message.ID
            if (-not $isCorrectID) {
                continue
            }

            $isUserChangeResponseType = $Envelope.Type -eq $PasswordResetResponseType
            if (-not $isUserChangeResponseType) {
                continue
            }

            $Response = $Envelope.Payload
            $isUserChangeResponse = Test-UserChangeResponse $Response -ErrorAction:SilentlyContinue
            if (-not $isUserChangeResponse) {
                continue
            }

            if ($VerbosePreference) {
                "Found valid user change response:`n{0}" -f ($Response | ConvertTo-Json) | Write-Verbose
            }
            if ($Response.Success -eq $true) {
                $Params = @{
                    RSAPrivateKey = $PrivateKey
                    EncryptedString = $Response.EncryptedPassword
                }
                $Password = ConvertFrom-EncryptedString @Params

                return $Password
            }
        }

        Start-Sleep -Seconds 1
    } while ($StopWatchTimer.Elapsed.Seconds -lt $Timeout)

    if ($null -ne $Response) {
        throw "error processing password change request: $($Response.Error)"
    }
    throw "timeout processing password change request"
}