Private/Invoke-ACME.ps1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
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 {
            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
 
    #>

}