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)]
        [string]$CSRPath,
        [Parameter(ParameterSetName='FromScratch',Position=1)]
        [ValidateScript({Test-ValidKeyLength $_ -ThrowOnFail})]
        [string]$KeyLength='2048',
        [Parameter(ParameterSetName='ImportKey',Mandatory)]
        [string]$KeyFile,
        [ValidateScript({Test-ValidPlugin $_ -ThrowOnFail})]
        [string[]]$Plugin,
        [hashtable]$PluginArgs,
        [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]$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]$Install,
        [switch]$UseSerialValidation,
        [int]$DnsSleep=120,
        [int]$ValidationTimeout=60,
        [string]$PreferredChain,
        [switch]$Force
    )

    # Make sure we have an account configured
    if (!($acct = Get-PAAccount)) {
        try { 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) {
        $CSRPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CSRPath)
        $csrDetails = Get-CsrDetails $CSRPath

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

    # 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
    $order = Get-PAOrder $Domain[0] -Refresh

    # 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 (!$Force) {

        # skip confirmation if the SANs 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
             (($SANs | Sort-Object) -join ',') -ne (($order.SANs | 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 (!$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 (!$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;
    }

    $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}
        }

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

    # send the request
    try {
        $response = Invoke-ACME $header $payloadJson $acct -EA Stop
    } catch { 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

    # 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
        FriendlyName        = $FriendlyName
        PfxPass             = $PfxPass
        Install             = $Install.IsPresent
        UseSerialValidation = $UseSerialValidation.IsPresent
        PreferredChain      = $PreferredChain
        AlwaysNewKey        = $AlwaysNewKey.IsPresent
    }

    # 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)
    }

    # 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($_) }
    }

    # add the Folder property
    $order | Add-Member 'Folder' (Get-OrderFolder $order.MainDomain) -Force

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

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

    # Make a local copy of the specified CSR file
    if ('FromCSR' -eq $PSCmdlet.ParameterSetName) {
        $csrDest = Join-Path $order.Folder 'request.csr'
        if ($CSRPath -ne $csrDest) {
            Copy-Item -Path $CSRPath -Destination $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




    <#
    .SYNOPSIS
        Create a new order on the current ACME account.
 
    .DESCRIPTION
        Creating an ACME order is the first step of the certificate request process. To create a SAN certificate with multiple names, include them all in an array for the -Domain parameter. The first name in the list will be considered the "MainDomain" and will also be in the certificate subject field. LetsEncrypt currently limits SAN certificates to 100 names.
 
        Be aware that only one order per MainDomain per ACME Account can exist with this module. Subsequent orders that have the same MainDomain will overwrite previous orders and certificates under the assumption that you are trying to renew or update the certificate with additional names.
 
    .PARAMETER Domain
        One or more domain names to include in this order/certificate. The first one in the list will be considered the "MainDomain" and be set as the subject of the finalized certificate.
 
    .PARAMETER CSRPath
        The path to a pre-made certificate request file in PEM (Base64) format. This is useful for appliances that need to generate their own keys and cert requests.
 
    .PARAMETER KeyLength
        The type and size of private key to use. For RSA keys, specify a number between 2048-4096 (divisible by 128). For ECC keys, specify either 'ec-256' or 'ec-384'. Defaults to '2048'.
 
    .PARAMETER KeyFile
        The path to an existing EC or RSA private key file. This will attempt to create the order using the specified key as the certificate's private key.
 
    .PARAMETER Plugin
        One or more validation plugin names to use for this order's challenges. If no plugin is specified, the DNS "Manual" plugin will be used. If the same plugin is used for all domains in the order, you can just specify it once. Otherwise, you should specify as many plugin names as there are domains in the order and in the same sequence as the order.
 
    .PARAMETER PluginArgs
        A hashtable containing the plugin arguments to use with the specified Plugin list. So if a plugin has a -MyText string and -MyNumber integer parameter, you could specify them as @{MyText='text';MyNumber=1234}.
 
    .PARAMETER DnsAlias
        One or more FQDNs that DNS challenges should be published to instead of the certificate domain's zone. This is used in advanced setups where a CNAME in the certificate domain's zone has been pre-created to point to the alias's FQDN which makes the ACME server check the alias domain when validation challenge TXT records. If the same alias is used for all domains in the order, you can just specify it once. Otherwise, you should specify as many alias FQDNs as there are domains in the order and in the same sequence as the order.
 
    .PARAMETER OCSPMustStaple
        If specified, the certificate generated for this order will have the OCSP Must-Staple flag set.
 
    .PARAMETER AlwaysNewKey
        If specified, the order will be configured to always generate a new private key during each renewal. Otherwise, the old key is re-used if it exists.
 
    .PARAMETER FriendlyName
        Set a friendly name for the certificate. This will populate the "Friendly Name" field in the Windows certificate store when the PFX is imported. Defaults to the first item in the Domain parameter.
 
    .PARAMETER PfxPass
        Set the export password for generated PFX files. Defaults to 'poshacme'. When the PfxPassSecure parameter is specified, this parameter is ignored.
 
    .PARAMETER PfxPassSecure
        Set the export password for generated PFX files using a SecureString value. When this parameter is specified, the PfxPass parameter is ignored.
 
    .PARAMETER Install
        If specified, the certificate generated for this order will be imported to the local computer's Personal certificate store.
 
    .PARAMETER UseSerialValidation
        If specified, the names in the order will be validated individually rather than all at once. This can significantly increase the time it takes to process validations and should only be used for plugins that require it. The plugin's usage guide should indicate whether it is required.
 
    .PARAMETER DnsSleep
        Number of seconds to wait for DNS changes to propagate before asking the ACME server to validate DNS challenges.
 
    .PARAMETER ValidationTimeout
        Number of seconds to wait for the ACME server to validate the challenges after asking it to do so. If the timeout is exceeded, an error will be thrown.
 
    .PARAMETER PreferredChain
        If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. If no match, the default offered chain will be used.
 
    .PARAMETER Force
        If specified, confirmation prompts that may have been generated will be skipped.
 
    .EXAMPLE
        New-PAOrder site1.example.com
 
        Create a new order for the specified domain using the default key length.
 
    .EXAMPLE
        New-PAOrder -Domain 'site1.example.com','site2.example.com','site3.example.com'
 
        Create a new SAN order for the specified domains using the default key length.
 
    .EXAMPLE
        New-PAOrder site1.example.com 4096
 
        Create a new order for the specified domain using an RSA 4096 bit key.
 
    .EXAMPLE
        New-PAOrder 'site1.example.com','site2.example.com' ec-384 -Force
 
        Create a new SAN order for the specified domains using an ECC key using P-384 curve that ignores any confirmations.
 
    .LINK
        Project: https://github.com/rmbolger/Posh-ACME
 
    .LINK
        Get-PAOrder
 
    .LINK
        Set-PAOrder
 
    #>

}