Private/Invoke-ACME.ps1

function Invoke-ACME {
    [CmdletBinding(DefaultParameterSetName='Account')]
    param(
        [Parameter(Mandatory,Position=0)]
        [hashtable]$Header,
        [Parameter(Mandatory,Position=1)]
        [AllowEmptyString()]
        [string]$PayloadJson,
        [Parameter(ParameterSetName='Account',Mandatory,Position=2)]
        [PSTypeName('PoshACME.PAAccount')]$Account,
        [Parameter(ParameterSetName='RawKey',Mandatory,Position=2)]
        [ValidateScript({Test-ValidKey $_ -ThrowOnFail})]
        [Security.Cryptography.AsymmetricAlgorithm]$Key,
        [switch]$NoRetry,
        [string]$OutFile
    )

    # make sure we have a server configured
    if (!(Get-PAServer)) {
        throw "No ACME server configured. Run Set-PAServer first."
    }

    # Because we're not refreshing the server on module load, we may not have a
    # NextNonce set yet. So check the header, and grab a fresh one if it's empty.
    if ([string]::IsNullOrWhiteSpace($Header.nonce)) {
        $Header.nonce = Get-Nonce
    }

    # set the account key based on the parameter set
    if ($PSCmdlet.ParameterSetName -eq 'Account') {
        # hydrate the account key
        $acctKey = $Account.key | ConvertFrom-Jwk
    } else {
        # use the one passed in
        $acctKey = $Key
    }

    # Validation on the rest of the header will be taken care of by New-Jws. And
    # the only reason we aren't just simplifying by changing the input param to a
    # completed JWS string is because we want to be able to auto-retry on errors
    # like badNonce which requires modifying the Header and re-signing a new JWS.
    $Jws = New-Jws $acctKey $Header $PayloadJson

    # since HTTP error codes make Invoke-WebRequest throw an exception,
    # we need to wrap it in a try/catch. But we can still get the response
    # object via the exception.

    try {
        $iwrSplat = @{
            Uri = $Header.url
            Body = $Jws
            Method = 'Post'
            ContentType = 'application/jose+json'
            UserAgent = $script:USER_AGENT
            Headers = $script:COMMON_HEADERS
            ErrorAction = 'Stop'
        }
        if (-not [String]::IsNullOrWhiteSpace($OutFile)) {
            $iwrSplat.OutFile = $OutFile
        }

        $response = Invoke-WebRequest @iwrSplat @script:UseBasic

        # update the next nonce if it was sent
        if ($response -and $response.Headers.ContainsKey($script:HEADER_NONCE)) {
            $script:Dir.nonce = $response.Headers[$script:HEADER_NONCE] | Select-Object -First 1
            Write-Debug "Updating nonce: $($script:Dir.nonce)"
        }

        return $response

    } catch {
        # Since we can't catch explicit exception types between PowerShell editions
        # without errors for non-existent types, we need to string match the type names
        # and re-throw anything we don't care about.
        $exType = $_.Exception.GetType().FullName
        if ('System.Net.WebException' -eq $exType) {

            # This is the exception that gets thrown in PowerShell Desktop edition

            # get the response object: System.Net.HttpWebResponse
            $ex = $_.Exception
            $response = $ex.Response

            # update the next nonce if it was sent
            if ($script:HEADER_NONCE -in $response.Headers) {
                $script:Dir.nonce = $response.GetResponseHeader($script:HEADER_NONCE) | Select-Object -First 1
                Write-Debug "Updating nonce from error response: $($script:Dir.nonce)"
                $freshNonce = $true
            }

            # grab the raw response body
            $sr = New-Object IO.StreamReader($response.GetResponseStream())
            $sr.BaseStream.Position = 0
            $sr.DiscardBufferedData()
            $body = $sr.ReadToEnd()
            Write-Debug "Error Body: $body"

        } elseif ('Microsoft.PowerShell.Commands.HttpResponseException' -eq $exType) {

            # This is the exception that gets thrown in PowerShell Core edition

            # get the response object
            # Linux type: System.Net.Http.CurlHandler+CurlResponseMessage
            # Mac type: ???
            # Win type: System.Net.Http.HttpResponseMessage
            $ex = $_.Exception
            $response = $ex.Response

            # update the next nonce if it was sent
            if ($script:HEADER_NONCE -in $response.Headers.Key) {
                $script:Dir.nonce = ($response.Headers | Where-Object { $_.Key -eq $script:HEADER_NONCE }).Value | Select-Object -First 1
                Write-Debug "Updating nonce from error response: $($script:Dir.nonce)"
                $freshNonce = $true
            }

            # Currently in PowerShell 6, there's no way to get the raw response body from an
            # HttpResponseException because they dispose the response stream.
            # https://github.com/PowerShell/PowerShell/issues/5555
            # https://get-powershellblog.blogspot.com/2017/11/powershell-core-web-cmdlets-in-depth.html
            # However, a "processed" version of the body is available via ErrorDetails.Message
            # which *should* work for us. The processing they're doing should only be removing HTML
            # tags. And since our body should be JSON, there shouldn't be any tags to remove.
            # So we'll just go with it for now until someone reports a problem.
            $body = $_.ErrorDetails.Message
            Write-Debug "Error Body: $body"

        } else { throw }

        # ACME uses RFC7807, Problem Details for HTTP APIs
        # https://tools.ietf.org/html/rfc7807
        # So a JSON parseable error object should be in the response body.
        try { $acmeError = $body | ConvertFrom-Json }
        catch {
            # Old endpoints won't necessarily throw rfc7807 bodies
            # for 404 errors. So we're going to fake them.
            # https://github.com/letsencrypt/boulder/issues/4540
            if (404 -eq $response.StatusCode) {
                $acmeError = @{
                    type = 'urn:ietf:params:acme:error:malformed'
                    detail = 'Page not found'
                    status = 404
                }
            } else {
                Write-Warning "Response body was not JSON parseable"
                # re-throw the original exception
                throw $ex
            }
        }

        # check for badNonce and retry once
        if (!$NoRetry -and $freshNonce -and $acmeError.type -and $acmeError.type -like '*:badNonce') {
            $Header.nonce = $script:Dir.nonce
            Write-Debug "Retrying with updated nonce"
            return (Invoke-ACME $Header $PayloadJson -Key $acctKey -NoRetry)
        }

        # throw the converted AcmeException
        throw [AcmeException]::new($acmeError.detail,$acmeError)
    }





    <#
    .SYNOPSIS
        Send an authenticated ACME protocol message.
 
    .DESCRIPTION
        This is an advanced function used to send custom commands to an ACME server. You must provide a proper header hashtable and JSON body. Then the function will sign the data with an account or raw key and send it.
 
    .PARAMETER Header
        A hashtable containing the appropriate fields for an ACME message header such as 'alg', 'jwk', 'kid', 'nonce', and 'url'. The url field is also used as the destination for the message.
 
    .PARAMETER PayloadJson
        A JSON formatted string with the ACME message body.
 
    .PARAMETER Account
        An existing ACME account object such as the output from Get-PAAccount.
 
    .PARAMETER Key
        A raw RSA or EC key object. This is usually only necessary when creating a new ACME account.
 
    .PARAMETER NoRetry
        If specified, don't retry on bad nonce errors. Occasionally, the nonce provided in an ACME message will be rejected. By default, this function requests a new nonce once and tries to send the message again before giving up.
 
    .PARAMETER OutFile
        Specifies the output file for which this function saves the response body. Enter a path and file name. If you omit the path, the default is the current location.
 
    .EXAMPLE
        $acct = Get-PAAccount
        PS C:\>$header = @{ alg=$acct.alg; kid=$acct.location; nonce='xxxxxxxxxxxxxx'; url='https://acme.example.com/acme/challenge/xxxxxxxxxxxxx' }
        PS C:\>$payloadJson = '{}'
        PS C:\>Invoke-ACME $header $payloadJson $acct
 
        Send an ACME message using the current account.
 
    .LINK
        Project: https://github.com/rmbolger/Posh-ACME
 
    .LINK
        New-PACertificate
 
    #>

}