Public/Set-PAAccount.ps1

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='2048',
        [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
        }

    }
}