Public/New-PAOrder.ps1

function New-PAOrder {
    [CmdletBinding(SupportsShouldProcess,DefaultParameterSetName='FromScratch')]
    [OutputType('PoshACME.PAOrder')]
    param(
        [Parameter(ParameterSetName='FromScratch',Mandatory,Position=0)]
        [Parameter(ParameterSetName='ImportKey',Mandatory,Position=0)]
        [string[]]$Domain,
        [Parameter(ParameterSetName='FromCSR',Mandatory,Position=0)]
        [Alias('CSRString')]
        [string]$CSRPath,
        [Parameter(ParameterSetName='FromScratch',Position=1)]
        [ValidateScript({Test-ValidKeyLength $_ -ThrowOnFail})]
        [string]$KeyLength='2048',
        [Parameter(ParameterSetName='ImportKey',Mandatory)]
        [string]$KeyFile,
        [ValidateScript({Test-ValidFriendlyName $_ -ThrowOnFail})]
        [string]$Name,
        [ValidateScript({Test-ValidPlugin $_ -ThrowOnFail})]
        [string[]]$Plugin,
        [hashtable]$PluginArgs,
        [ValidateRange(0, 3650)]
        [int]$LifetimeDays,
        [string[]]$DnsAlias,
        [Parameter(ParameterSetName='FromScratch')]
        [Parameter(ParameterSetName='ImportKey')]
        [switch]$OCSPMustStaple,
        [Parameter(ParameterSetName='FromScratch')]
        [Parameter(ParameterSetName='ImportKey')]
        [switch]$AlwaysNewKey,
        [Parameter(ParameterSetName='FromScratch')]
        [Parameter(ParameterSetName='ImportKey')]
        [string]$Subject,
        [Parameter(ParameterSetName='FromScratch')]
        [Parameter(ParameterSetName='ImportKey')]
        [string]$FriendlyName,
        [Parameter(ParameterSetName='FromScratch')]
        [Parameter(ParameterSetName='ImportKey')]
        [string]$PfxPass='poshacme',
        [Parameter(ParameterSetName='FromScratch')]
        [Parameter(ParameterSetName='ImportKey')]
        [ValidateScript({Test-SecureStringNotNullOrEmpty $_ -ThrowOnFail})]
        [securestring]$PfxPassSecure,
        [Parameter(ParameterSetName='FromScratch')]
        [Parameter(ParameterSetName='ImportKey')]
        [switch]$UseModernPfxEncryption,
        [Parameter(ParameterSetName='FromScratch')]
        [Parameter(ParameterSetName='ImportKey')]
        [switch]$Install,
        [switch]$UseSerialValidation,
        [int]$DnsSleep=120,
        [int]$ValidationTimeout=60,
        [string]$PreferredChain,
        [switch]$Force,
        [string]$ReplacesCert
    )

    try {
        # Make sure we have an account configured
        if (-not ($acct = Get-PAAccount)) {
            throw "No ACME account configured. Run Set-PAAccount or New-PAAccount first."
        }
    }
    catch { $PSCmdlet.ThrowTerminatingError($_) }

    # If using a pre-generated CSR, extract the details so we can generate expected parameters
    if ('FromCSR' -eq $PSCmdlet.ParameterSetName) {
        $csrDetails = Get-CsrDetails $CSRPath

        $Domain = $csrDetails.Domain
        $KeyLength = $csrDetails.KeyLength
        $OCSPMustStaple = New-Object Management.Automation.SwitchParameter($csrDetails.OCSPMustStaple)
    }

    # De-dupe the domain list if necessary
    $domainCount = $Domain.Count
    $Domain = $Domain | Select-Object -Unique
    if ($domainCount -gt $Domain.Count) {
        Write-Warning "One or more duplicate domain values found. Removing duplicates."
    }

    # If importing a key, make sure it's valid so we can set the appropriate KeyLength
    if ('ImportKey' -eq $PSCmdlet.ParameterSetName) {
        $KeyFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($KeyFile)
        try {
            $kLength = [string]::Empty
            # we don't actually care about the key object, just the parsed length
            $null = New-PAKey -KeyFile $KeyFile -ParsedLength ([ref]$kLength)
            $KeyLength = $kLength
        }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    # PfxPassSecure takes precedence over PfxPass if both are specified but we
    # need the value in plain text. So we'll just take over the PfxPass variable
    # to use for the rest of the function.
    if ($PfxPassSecure) {
        # throw a warning if they also specified PfxPass
        if ('PfxPass' -in $PSBoundParameters.Keys) {
            Write-Warning "PfxPass and PfxPassSecure were both specified. Using value from PfxPassSecure."
        }

        # override the existing PfxPass parameter
        $PfxPass = [pscredential]::new('u',$PfxPassSecure).GetNetworkCredential().Password
        $PSBoundParameters.PfxPass = $PfxPass
    }

    # check for an existing order
    if ($Name) {
        $order = Get-PAOrder -Name $Name
    } else {
        $order = Get-PAOrder -MainDomain $Domain[0]

        # set the default Name to a filesystem friendly version of the first domain
        $Name = $Domain[0].Replace('*','!')
    }

    # separate the SANs
    $SANs = @($Domain | Where-Object { $_ -ne $Domain[0] })

    # There's a chance we may be overwriting an existing order here. So check for
    # confirmation if certain conditions are true
    if (-not $Force) {

        $oldDomains = (@($order.MainDomain) + @($order.SANs) | Sort-Object) -join ','

        # skip confirmation if the Domains or KeyLength are different regardless
        # of the original order status or if the order is pending but expired
        if ( ($order -and ($KeyLength -ne $order.KeyLength -or
             ($oldDomains -ne ($Domain | Sort-Object) -join ',') -or
             ($order.status -eq 'pending' -and (Get-DateTimeOffsetNow) -gt ([DateTimeOffset]::Parse($order.expires))) ))) {
            # do nothing

        # confirm if previous order is still in progress
        } elseif ($order -and $order.status -in 'pending','ready','processing') {

            if (-not $PSCmdlet.ShouldContinue("Do you wish to overwrite?",
                "Existing order with status $($order.status).")) { return }

        # confirm if previous order not up for renewal
        } elseif ($order -and $order.status -eq 'valid' -and
                    (Get-DateTimeOffsetNow) -lt ([DateTimeOffset]::Parse($order.RenewAfter))) {

            if (-not $PSCmdlet.ShouldContinue("Do you wish to overwrite?",
                "Existing order has not reached suggested renewal window.")) { return }
        }
    }

    Write-Debug "Creating new $KeyLength order with domains: $($Domain -join ', ')"

    # Force a key change if the KeyLength is different than the old order
    if ($order -and $order.KeyLength -ne $KeyLength) {
        $ForceNewKey = $true
    }

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

    # super lazy IPv4 address regex, but we just need to be able to
    # distinguish from an FQDN
    $reIPv4 = [regex]'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'

    # build the payload object
    $payload = @{identifiers=@()}
    foreach ($d in $Domain) {

        # IP identifiers (RFC8738) are an extension to the original ACME protocol
        # https://tools.ietf.org/html/rfc8738
        #
        # So we have to distinguish between domain FQDNs and IPv4/v6 addresses
        # and send the appropriate identifier type for each one. We don't care
        # if the IP address entered is actually valid or not, only that it is
        # parsable as an IP address and should be sent as one rather than a
        # DNS name.

        if ($d -match $reIPv4 -or $d -like '*:*') {
            Write-Debug "$d identified as IP address. Attempting to parse."
            $ip = [ipaddress]$d

            $payload.identifiers += @{type='ip';value=$ip.ToString()}
        }
        else {
            $payload.identifiers += @{type='dns';value=$d}
        }

    }

    # add the requested certificate lifetime if specified
    if ($LifetimeDays) {
        $now = [DateTimeOffset]::UtcNow
        $notBefore = $now.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture)
        $notAfter = $now.AddDays($LifetimeDays).ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture)
        $payload.notBefore = $notBefore
        $payload.notAfter = $notAfter
    }

    # Add the ARI replaces field if supported and specified
    # https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#name-extensions-to-the-order-obj
    if ($ReplacesCert -and -not (Get-PAServer).DisableARI -and (Get-PAServer).renewalInfo) {
        $payload.replaces = $ReplacesCert
    }

    $payloadJson = $payload | ConvertTo-Json -Depth 5 -Compress

    # send the request
    try {
        $response = Invoke-ACME $header $payloadJson $acct -EA Stop
    } catch {
        # ACME server should send HTTP 409 Conflict status if we tried to specify
        # a 'replaces' value that has already been replaced. So if we get that,
        # retry the request without that field included.
        if (409 -eq $_.Exception.Data.status) {
            Write-Warning $_.Exception.Data.detail
            Write-Verbose "Resubmitting new order without 'replaces' field."
            $payload.Remove('replaces')
            $payloadJson = $payload | ConvertTo-Json -Depth 5 -Compress
            try {
                $response = Invoke-ACME $header $payloadJson $acct -EA Stop
            } catch { throw }
        } else {
            throw
        }
    }

    # process the response
    $order = $response.Content | ConvertFrom-Json
    $order.PSObject.TypeNames.Insert(0,'PoshACME.PAOrder')

    # fix any dates that may have been parsed by PSCore's JSON serializer
    $order.expires = Repair-ISODate $order.expires

    # add the location from the header
    if ($response.Headers.ContainsKey('Location')) {
        $location = $response.Headers['Location'] | Select-Object -First 1
        Write-Debug "Adding location $location"
        $order | Add-Member -MemberType NoteProperty -Name 'location' -Value $location
    } else {
        try { throw 'No Location header found in newOrder output' }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    # Make sure the returned order isn't a duplicate of one we already have a copy
    # of locally. This can happen with Let's Encrypt when an existing order with the
    # same identifiers is still in the 'pending' or 'ready' state.
    $orderConflict = Get-PAOrder -List | Where-Object { $_.location -eq $location -and $_.Name -ne $Name }
    if ($orderConflict) {
        try { throw "ACME Server returned duplicate order details that match existing local order '$($orderConflict.Name)'" }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    # Per https://tools.ietf.org/html/rfc8555#section-7.1.3
    # In the returned order object, there is no guarantee that the list of identifiers
    # match the sequence they were submitted in. The list of authorizations may not match
    # either. And the identifiers and authorizations may not even match each other's
    # sequence.
    #
    # Unfortunately, things like DNS plugins and challenge aliases currently depend on
    # the assumption that the sequence of identifiers and the sequence of authorizations
    # all match the original sequence of the submitted domains. So we need to make sure
    # that it's true until we refactor things so those assumptions aren't necessary anymore.

    # set the order's identifiers to the original payload's identifiers since that was
    # correct already
    $order.identifiers = $payload.identifiers

    # unfortunately, there's no way to know which authorization URL is for which identifier
    # just by parsing it. So we need to query the details for each one in order to put them
    # in the right order
    $auths = Get-PAAuthorization $order.authorizations
    for ($i=0; $i -lt $order.identifiers.Count; $i++) {
        $auth = $auths | Where-Object { $_.fqdn -eq $order.identifiers[$i].value }
        $order.authorizations[$i] = $auth.location
    }

    # make sure FriendlyName is non-empty
    if ([String]::IsNullOrWhiteSpace($FriendlyName)) {
        $FriendlyName = $Domain[0]
    }

    # add additional members we'll need for later
    $order | Add-Member -NotePropertyMembers @{
        MainDomain          = $Domain[0]
        SANs                = $SANs
        KeyLength           = $KeyLength
        CertExpires         = $null
        RenewAfter          = $null
        OCSPMustStaple      = $OCSPMustStaple.IsPresent
        Plugin              = @('Manual')
        DnsAlias            = $null
        DnsSleep            = $DnsSleep
        ValidationTimeout   = $ValidationTimeout
        Subject             = $Subject
        FriendlyName        = $FriendlyName
        PfxPass             = $PfxPass
        Install             = $Install.IsPresent
        UseSerialValidation = $UseSerialValidation.IsPresent
        PreferredChain      = $PreferredChain
        AlwaysNewKey        = $AlwaysNewKey.IsPresent
        LifetimeDays        = $null
    }

    # override AlwaysNewKey if they're importing the private key
    if ($order.AlwaysNewKey -and 'ImportKey' -eq $PSCmdlet.ParameterSetName) {
        Write-Warning "AlwaysNewKey was disabled because private key was imported using the KeyFile parameter."
        $order.AlwaysNewKey = $false
    }

    # make sure there's a certificate field for later
    if ('certificate' -notin $order.PSObject.Properties.Name) {
        $order | Add-Member 'certificate' $null
    }

    # add the CSR data if we have it
    if ('FromCSR' -eq $PSCmdlet.ParameterSetName) {
        $order | Add-Member 'CSRBase64Url' $csrDetails.Base64Url
    }

    # update other optional fields
    if ('Plugin' -in $PSBoundParameters.Keys) {
        $order.Plugin = @($Plugin)
    }
    if ('DnsAlias' -in $PSBoundParameters.Keys) {
        $order.DnsAlias = @($DnsAlias)
    }
    if ('LifetimeDays' -in $PSBoundParameters.Keys) {
        $order.LifetimeDays = $LifetimeDays
    }
    if ('UseModernPfxEncryption' -in $PSBoundParameters.Keys) {
        $order | Add-Member UseModernPfxEncryption $UseModernPfxEncryption.IsPresent -Force
    }

    # add the Name and Folder properties
    $order | Add-Member 'Name' $Name -Force
    $order | Add-Member 'Folder' (Join-Path $acct.Folder $Name) -Force

    # save it to memory and disk
    $order.Name | Out-File (Join-Path $acct.Folder 'current-order.txt') -Force -EA Stop
    $script:Order = $order
    Update-PAOrder $order -SaveOnly

    # export plugin args now that the order exists on disk
    if ('PluginArgs' -in $PSBoundParameters.Keys) {
        Export-PluginArgs -Order $order -PluginArgs $PluginArgs
    }

    # Make a local copy of the specified CSR
    if ('FromCSR' -eq $PSCmdlet.ParameterSetName) {
        $csrDest = Join-Path $order.Folder 'request.csr'
        Export-Pem $csrDetails.PemLines $csrDest
    }

    # Determine whether to remove the old private key. This is necessary if it exists
    # and we're using a CSR or it's explicitly requested or the new KeyLength doesn't match the old one.
    $keyPath = Join-Path $order.Folder 'cert.key'
    $removeOldKey = ( (Test-Path $keyPath -PathType Leaf) -and
                      ($order.AlwaysNewKey -or $ForceNewKey -or 'FromCSR' -eq $PSCmdlet.ParameterSetName) )

    # backup the old private key if necessary
    if ($removeOldKey) {
        Write-Verbose "Removing old private key"
        $oldKey = Get-ChildItem $keyPath
        $oldKey | Move-Item -Destination { "$($_.FullName).bak" } -Force
    }

    # backup any old certs/requests that might exist
    $oldFiles = Get-ChildItem (Join-Path $order.Folder *) -Include cert.cer,cert.pfx,chain.cer,fullchain.cer,fullchain.pfx
    $oldFiles | Move-Item -Destination { "$($_.FullName).bak" } -Force

    # remove old chain files
    Get-ChildItem (Join-Path $order.Folder 'chain*.cer') -Exclude chain.cer |
        Remove-Item -Force

    # Make a local copy of the private key if it was specified.
    if ('ImportKey' -eq $PSCmdlet.ParameterSetName) {
        if ($keyPath -ne $KeyFile) {
            Copy-Item -Path $KeyFile -Destination $keyPath
        }
    }

    return $order
}