Public/Set-PAAccount.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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
function Set-PAAccount {
    [CmdletBinding(SupportsShouldProcess,DefaultParameterSetName='Edit')]
    param(
        [Parameter(Position=0,ValueFromPipeline,ValueFromPipelineByPropertyName)]
        [ValidateScript({Test-ValidFriendlyName $_ -ThrowOnFail})]
        [Alias('Name')]
        [string]$ID,
        [Parameter(ParameterSetName='Edit',Position=1)]
        [string[]]$Contact,
        [Parameter(ParameterSetName='Edit')]
        [ValidateScript({Test-ValidFriendlyName $_ -ThrowOnFail})]
        [string]$NewName,
        [Parameter(ParameterSetName='Edit')]
        [switch]$UseAltPluginEncryption,
        [Parameter(ParameterSetName='Edit')]
        [switch]$ResetAltPluginEncryption,
        [Parameter(ParameterSetName='Edit')]
        [switch]$Deactivate,
        [Parameter(ParameterSetName='Edit')]
        [switch]$Force,
        [Parameter(ParameterSetName='Rollover',Mandatory)]
        [Parameter(ParameterSetName='RolloverImportKey',Mandatory)]
        [switch]$KeyRollover,
        [Parameter(ParameterSetName='Rollover')]
        [ValidateScript({Test-ValidKeyLength $_ -ThrowOnFail})]
        [Alias('AccountKeyLength')]
        [string]$KeyLength='ec-256',
        [Parameter(ParameterSetName='RolloverImportKey',Mandatory)]
        [string]$KeyFile,
        [switch]$NoSwitch
    )

    Begin {
        # make sure we have a server configured
        if (-not ($server = Get-PAServer)) {
            try { throw "No ACME server configured. Run Set-PAServer first." }
            catch { $PSCmdlet.ThrowTerminatingError($_) }
        }

        # make sure all Contacts have a mailto: prefix which is the only
        # type of contact currently supported.
        if ($Contact.Count -gt 0) {
            0..($Contact.Count-1) | ForEach-Object {
                if ($Contact[$_] -notlike 'mailto:*') {
                    $Contact[$_] = "mailto:$($Contact[$_])"
                }
            }
        }
    }

    Process {

        # There are 3 types of calls the user might be making here.
        # - account switch
        # - account switch and modification
        # - modification only (possibly bulk via pipeline)
        # The default is to switch accounts. So we have a -NoSwitch parameter to
        # indicate a non-switching modification. But there's a chance they could forget
        # to use it for a bulk update. For now, we'll just let it happen and switch
        # to whatever account came through the pipeline last.

        # make sure there's an account associated with the specified ID or
        # a current account
        if ($ID) {
            if (-not ($acct = Get-PAAccount -ID $ID)) {
                Write-Warning "Specified account ID ($ID) was not found. No changes made."
                return
            }
            # check if we're switching
            $oldAcct = Get-PAAccount
            if ($oldAcct -and $oldAcct.id -eq $acct.id) {
                $NoSwitch = $true
            }
        } else {
            if (-not ($acct = Get-PAAccount)) {
                try { throw "No ACME account configured. Run New-PAAccount or specify an account ID." }
                catch { $PSCmdlet.ThrowTerminatingError($_) }
            }
            $NoSwitch = $true
        }

        # switch the current account unless told not to or it's not changing
        if (-not $NoSwitch) {
            # refresh the cached copy
            Update-PAAccount $acct.id

            Write-Debug "Switching to account $($acct.id)"

            # save it as current
            $acct.id | Out-File (Join-Path $server.Folder 'current-account.txt') -Force -EA Stop

            # reload the cache from disk
            Import-PAConfig -Level 'Account'

            # grab a local reference to the newly current account
            $acct = Get-PAAccount
        }

        $saveAccount = $false

        # deal with local changes
        if ('UseAltPluginEncryption' -in $PSBoundParameters.Keys -or $ResetAltPluginEncryption) {

            # UseAltPluginEncryption takes precedence over ResetAltPluginEncryption
            # So if Use:$false + Reset is specified, the key is removed rather than rotated

            if ([String]::IsNullOrEmpty($acct.sskey) -and $UseAltPluginEncryption)
            {
                Write-Verbose "Adding new sskey for account $($acct.ID)"
                Update-PluginEncryption $acct.ID -NewKey (New-AesKey)
            }
            elseif (-not [String]::IsNullOrEmpty($acct.sskey) -and
                    'UseAltPluginEncryption' -in $PSBoundParameters.Keys -and
                    -not $UseAltPluginEncryption)
            {
                Write-Verbose "Removing sskey for account $($acct.ID)"
                Update-PluginEncryption $acct.ID -NewKey $null
            }
            elseif (-not [String]::IsNullOrEmpty($acct.sskey) -and $ResetAltPluginEncryption) {
                Write-Verbose "Changing sskey for account $($acct.ID)"
                Update-PluginEncryption $acct.ID -NewKey (New-AesKey)
            }
        }

        # deal with server side changes
        if ('Contact' -in $PSBoundParameters.Keys -or $Deactivate) {

            # build the header
            $header = @{
                alg   = $acct.alg;
                kid   = $acct.location;
                nonce = $script:Dir.nonce;
                url   = $acct.location;
            }

            # build the payload
            $payload = @{}
            if ('Contact' -in $PSBoundParameters.Keys) {
                # We want to allow people to clear their contact field either by specifying $null or an empty array @()
                # But currently, Boulder only works by sending the empty array. So use that if they sent $null.
                if (-not $Contact) {
                    $payload.contact = @()
                } else {
                    $payload.contact = $Contact
                }
            }
            if ($Deactivate) {
                if (-not $Force) {
                    if (-not $PSCmdlet.ShouldContinue("Are you sure you wish to deactivate account $($acct.id)?",
                    "Deactivating an account is irreversible and will prevent modifications or renewals for associated orders and certificates.")) {
                        Write-Verbose "Deactivation aborted for account $($acct.id)."
                        return
                    }
                }
                $payload.status = 'deactivated'
            }

            # convert it to json
            $payloadJson = $payload | ConvertTo-Json -Depth 5 -Compress

            # send the request
            try {
                $response = Invoke-ACME $header $payloadJson $acct -EA Stop
            } catch { throw }

            $respObj = ($response.Content | ConvertFrom-Json)

            # update the things that could have changed
            $acct.status = $respObj.status
            $acct.contact = $respObj.contact

            $saveAccount = $true
        }

        # deal with key rollover
        if ($KeyRollover) {

            # We've been asked to rollover the account key which effectively means replace it
            # with a new one. The spec describes the process in the following link.
            # https://tools.ietf.org/html/rfc8555#section-7.3.5

            # build the standard outer header
            $header = @{
                alg   = $acct.alg;
                kid   = $acct.location;
                nonce = $script:Dir.nonce;
                url   = $script:Dir.keyChange;
            }

            if ($KeyFile) {
                # attempt to use the specified key as the new account key
                try {
                    $kLength = [string]::Empty
                    $newKey = New-PAKey -KeyFile $KeyFile -ParsedLength ([ref]$kLength)
                    $KeyLength = $kLength
                }
                catch { $PSCmdlet.ThrowTerminatingError($_) }

            } else {
                # generate a new account key
                $newKey = New-PAKey $KeyLength
            }

            # create the algorithm identifier as described by
            # https://tools.ietf.org/html/rfc7518#section-3.1
            # and what we know LetsEncrypt supports today which includes
            # RS256 for all RSA keys
            # ES256 for P-256 keys, ES384 for P-384 keys, ES512 for P-521 keys
            $alg = 'RS256'
            if     ($KeyLength -eq 'ec-256') { $alg = 'ES256' }
            elseif ($KeyLength -eq 'ec-384') { $alg = 'ES384' }
            elseif ($KeyLength -eq 'ec-521') { $alg = 'ES512' }

            # build the inner header
            $innerHead = @{
                alg  = $alg;
                jwk  = ($newKey | ConvertTo-Jwk -PublicOnly);
                url  = $script:Dir.keyChange;
            }

            # build the inner payload
            $innerPayloadJson = @{
                account = $acct.location;
                oldKey  = $acct.Key | ConvertFrom-Jwk | ConvertTo-Jwk -PublicOnly
            } | ConvertTo-Json -Depth 5 -Compress

            # build the outer payload by creating a signed JWS from
            # the inner header/payload and new key
            $payloadJson = New-Jws $newKey $innerHead $innerPayloadJson -NoHeaderValidation

            # send the request
            try {
                Invoke-ACME $header $payloadJson $acct -EA Stop | Out-Null

                # https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.5
                # Success is indicated by an HTTP 200 response. No response body
                # is required even though Let's Encrypt sends one.

                # So if we haven't caught an error, update the account with the
                # new key
                $acct.key = $newKey | ConvertTo-Jwk
                $acct.alg = $alg
                $acct.KeyLength = $KeyLength

                $saveAccount = $true

            } catch { throw }

        }

        # Deal with potential name change
        if ($NewName -and $NewName -ne $acct.id) {

            $newFolder = Join-Path $server.Folder $NewName
            if (Test-Path $newFolder) {
                Write-Error "Failed to rename PAAccount $($acct.id). The path '$newFolder' already exists."
            } else {
                # rename the dir folder
                Write-Debug "Renaming $($acct.id) account folder to $newFolder"
                try {
                    Rename-Item $acct.Folder $newFolder -EA Stop

                    # update the id/Folder in memory
                    $acct.id = $NewName
                    $acct.Folder = $newFolder

                    # update the current account ref if necessary
                    $curAcctFile = (Join-Path $server.Folder 'current-account.txt')
                    if ($acct.id -ne (Get-Content $curAcctFile -EA Ignore)) {
                        Write-Debug "Updating current-account.txt"
                        $NewName | Out-File $curAcctFile -Force -EA Stop
                    }

                    $saveAccount = $true
                }
                catch {
                    Write-Error $_
                }
            }
        }

        if ($saveAccount) {
            # save it to disk
            $acctFile = Join-Path $server.Folder "$($acct.id)\acct.json"
            $acct | Select-Object -Property * -ExcludeProperty id,Folder |
                ConvertTo-Json -Depth 5 |
                Out-File $acctFile -Force -EA Stop

            # reload config from disk
            Import-PAConfig -Level Account
        }

    }
}