Public/New-PACertificate.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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
function New-PACertificate {
    [CmdletBinding(DefaultParameterSetName='FromScratch')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText','')]
    param(
        [Parameter(ParameterSetName='FromScratch',Mandatory,Position=0)]
        [string[]]$Domain,
        [Parameter(ParameterSetName='FromCSR',Mandatory,Position=0)]
        [string]$CSRPath,
        [string[]]$Contact,
        [Parameter(ParameterSetName='FromScratch')]
        [ValidateScript({Test-ValidKeyLength $_ -ThrowOnFail})]
        [string]$CertKeyLength='2048',
        [Parameter(ParameterSetName='FromScratch')]
        [switch]$NewCertKey,
        [switch]$AcceptTOS,
        [ValidateScript({Test-ValidKeyLength $_ -ThrowOnFail})]
        [string]$AccountKeyLength='ec-256',
        [ValidateScript({Test-ValidDirUrl $_ -ThrowOnFail})]
        [Alias('location')]
        [string]$DirectoryUrl='LE_PROD',
        [ValidateScript({Test-ValidDnsPlugin $_ -ThrowOnFail})]
        [string[]]$DnsPlugin,
        [hashtable]$PluginArgs,
        [string[]]$DnsAlias,
        [Parameter(ParameterSetName='FromScratch')]
        [switch]$OCSPMustStaple,
        [Parameter(ParameterSetName='FromScratch')]
        [string]$FriendlyName='',
        [Parameter(ParameterSetName='FromScratch')]
        [string]$PfxPass='poshacme',
        [Parameter(ParameterSetName='FromScratch')]
        [ValidateScript({Test-WinOnly -ThrowOnFail})]
        [switch]$Install,
        [switch]$Force,
        [int]$DNSSleep=120,
        [int]$ValidationTimeout=60
    )

    # Make sure we have a server set. But don't override the current
    # one unless explicitly specified.
    if (!(Get-PAServer) -or ('DirectoryUrl' -in $PSBoundParameters.Keys)) {
        Set-PAServer $DirectoryUrl
    } else {
        # refresh the directory info (which should also get a fresh nonce)
        Update-PAServer
    }
    Write-Verbose "Using directory $($script:Dir.location)"

    # Make sure we have an account set. If Contact and/or AccountKeyLength
    # were specified and don't match the current one but do match a different,
    # one, switch to that. If the specified details don't match any existing
    # accounts, create a new one.
    $acct = Get-PAAccount
    $accts = @(Get-PAAccount -List -Refresh -Status 'valid' @PSBoundParameters)
    if (!$accts -or $accts.Count -eq 0) {
        # no matches for the set of filters, so create new
        Write-Verbose "Creating a new $AccountKeyLength account with contact: $($Contact -join ', ')"
        $acct = New-PAAccount @PSBoundParameters
    } elseif ($accts.Count -gt 0 -and (!$acct -or $acct.id -notin $accts.id)) {
        # we got matches, but there's no current account or the current one doesn't match
        # so set the first match as current
        $acct = $accts[0]
        Set-PAAccount $acct.id
    }
    Write-Verbose "Using account $($acct.id)"

    # 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
        $CertKeyLength = $csrDetails.KeyLength
        $OCSPMustStaple = New-Object Management.Automation.SwitchParameter($csrDetails.OCSPMustStaple)
    }

    # Check for an existing order from the MainDomain for this call and create a new
    # one if:
    # - -Force was used
    # - it doesn't exist
    # - is invalid
    # - is valid and within the renewal window
    # - is pending, but expired
    # - has different KeyLength
    # - has different SANs
    # - has different CSR
    $order = Get-PAOrder $Domain[0] -Refresh
    $SANs = @($Domain | Where-Object { $_ -ne $Domain[0] }) | Sort-Object
    if ($Force -or !$order -or
        $order.status -eq 'invalid' -or
        ($order.status -eq 'valid' -and $order.RenewAfter -and (Get-DateTimeOffsetNow) -ge ([DateTimeOffset]::Parse($order.RenewAfter))) -or
        ($order.status -eq 'pending' -and (Get-DateTimeOffsetNow) -gt ([DateTimeOffset]::Parse($order.expires))) -or
        $CertKeyLength -ne $order.KeyLength -or
        ($SANs -join ',') -ne (($order.SANs | Sort-Object) -join ',') -or
        ($csrDetails -and $csrDetails.Base64Url -ne $order.CSRBase64Url ) ) {

        if ($order) { $oldOrder = $order }

        # Create a hashtable of order parameters to splat
        if ('FromCSR' -eq $PSCmdlet.ParameterSetName) {
            $orderParams = @{ CSRPath = $CSRPath }

        } else {
            $orderParams = @{
                Domain         = $Domain;
                KeyLength      = $CertKeyLength;
                OCSPMustStaple = $OCSPMustStaple.IsPresent;
                NewKey         = $NewCertKey.IsPresent;
                Install        = $Install.IsPresent;
                FriendlyName   = $FriendlyName;
                PfxPass        = $PfxPass;
            }

            # load values from the old order if they exist and weren't explicitly specified
            if ($order) {
                if ('CertKeyLength' -notin $PSBoundParameters.Keys) {
                    $orderParams.KeyLength = $oldOrder.KeyLength
                }
                if ('OCSPMustStaple' -notin $PSBoundParameters.Keys) {
                    $orderParams.OCSPMustStaple = $oldOrder.OCSPMustStaple
                }
                if ('Install' -notin $PSBoundParameters.Keys -and $oldOrder.Install) {
                    $orderParams.Install = $oldOrder.Install
                }
                if ('FriendlyName' -notin $PSBoundParameters.Keys -and $oldOrder.FriendlyName) {
                    $orderParams.FriendlyName = $oldOrder.FriendlyName
                }
                if ('PfxPass' -notin $PSBoundParameters.Keys -and $oldOrder.PfxPass) {
                    $orderParams.PfxPass = $oldOrder.PfxPass
                }
            }
        }

        # and force a new order
        Write-Verbose "Creating a new order for $($Domain -join ', ')"
        $order = New-PAOrder @orderParams -Force

    } else {
        # set the existing order as current
        Write-Verbose "Using existing order for $($order.MainDomain) with status $($order.status)"
        $oldOrder = $order
        $order | Set-PAOrder
    }

    # add validation parameters to the order object using explicit params
    # backed up by previous order params
    if ('DnsPlugin' -in $PSBoundParameters.Keys) {
        $script:Order.DnsPlugin = $DnsPlugin
    } elseif ($oldOrder) {
        $script:Order.DnsPlugin = $oldOrder.DnsPlugin
    }
    if ('DnsAlias' -in $PSBoundParameters.Keys) {
        $script:Order.DnsAlias = $DnsAlias
    } elseif ($oldOrder) {
        $script:Order.DnsAlias = $oldOrder.DnsAlias
    }
    $script:Order.DnsSleep = $DnsSleep
    if ($oldOrder -and 'DnsSleep' -notin $PSBoundParameters.Keys) {
        $script:Order.DnsSleep = $oldOrder.DnsSleep
    }
    $script:Order.ValidationTimeout = $ValidationTimeout
    if ($oldOrder -and 'ValidationTimeout' -notin $PSBoundParameters.Keys) {
        $script:Order.ValidationTimeout = $oldOrder.ValidationTimeout
    }
    Write-Debug "Saving validation params to order"
    Update-PAOrder -SaveOnly
    $order = $script:Order

    # deal with "pending" orders that may have authorization challenges to prove
    if ($order.status -eq 'pending') {

        # create a hashtable of validation parameters to splat that uses
        # explicit params backed up by previous order params
        $chalParams = @{
            DnsSleep = $order.DnsSleep
            ValidationTimeout = $order.ValidationTimeout
        }
        if ($order.DnsPlugin) {
            $chalParams.DnsPlugin = $order.DnsPlugin
        }
        if ($order.DnsAlias) {
            $chalParams.DnsAlias = $order.DnsAlias
        }
        if ('PluginArgs' -in $PSBoundParameters.Keys) {
            $chalParams.PluginArgs = $PluginArgs
        }

        Submit-ChallengeValidation @chalParams

        # refresh the order status
        $order = Get-PAOrder -Refresh
    }

    # if we've reached this point, it should mean the order status is 'ready' and
    # we're ready to finalize the order.
    if ($order.status -eq 'ready') {

        # make the finalize call
        Write-Verbose "Finalizing the order."
        Submit-OrderFinalize

        # refresh the order status
        $order = Get-PAOrder -Refresh
    }

    # The order should now be finalized and the status should be valid. The only
    # thing left to do is download the cert and chain and write the results to
    # disk
    if ($order.status -eq 'valid' -and !$order.CertExpires) {
        if ([string]::IsNullOrWhiteSpace($order.certificate)) {
            throw "Order status is valid, but no certificate URL was found."
        }

        # Download the cert chain, split it up, and generate a PFX files
        Export-PACertFiles $order

        # check the certificate expiration date so we can update the CertExpires
        # and RenewAfter fields
        Write-Verbose "Updating cert expiration and renewal window"
        $certExpires = (Import-Pem (Join-Path $script:OrderFolder 'cert.cer')).NotAfter
        $script:Order.CertExpires = $certExpires.ToString('yyyy-MM-ddTHH:mm:ssZ')
        $script:Order.RenewAfter = $certExpires.AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ')
        Update-PAOrder -SaveOnly

        Write-Verbose "Successfully created certificate."

        # install to local computer store if asked
        if ($order.Install) {
            Write-Verbose "Importing certificate to Windows certificate store."
            if ([string]::IsNullOrEmpty($PfxPass)) {
                $secPass = New-Object Security.SecureString
            } else {
                $secPass = ConvertTo-SecureString $PfxPass -AsPlainText -Force
            }
            Import-PfxCertInternal (Join-Path $script:OrderFolder 'fullchain.pfx') -PfxPass $secPass
        }

        # output cert details
        Get-PACertificate

    } elseif ($order.CertExpires) {
        Write-Verbose "This certificate order has already been completed. Use -Force to overwrite the current certificate."
    }





    <#
    .SYNOPSIS
        Request a new certificate
 
    .DESCRIPTION
        This is the primary function for this module and is capable executing the entire ACME certificate request process from start to finish without any prerequisite steps. However, utilizing the module's other functions can enable more complicated workflows and reduce the number of parameters you need to supply to this function.
 
    .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 Contact
        One or more email addresses to associate with this certificate. These addresses will be used by the ACME server to send certificate expiration notifications or other important account notices.
 
    .PARAMETER CertKeyLength
        The type and size of private key to use for the certificate. 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 NewCertKey
        If specified, a new private key will be generated for the certificate. Otherwise, a new key will only be generated if one doesn't already exist for the primary domain or the key type or length have changed from the previous order.
 
    .PARAMETER AcceptTOS
        This switch is required when creating a new account as part of a certificate request. It implies you have read and accepted the Terms of Service for the ACME server you are connected to. The first time you connect to an ACME server, a link to the Terms of Service should have been displayed.
 
    .PARAMETER AccountKeyLength
        The type and size of private key to use for the account associated with this certificate. For RSA keys, specify a number between 2048-4096 (divisible by 128). For ECC keys, specify either 'ec-256' or 'ec-384'. Defaults to 'ec-256'.
 
    .PARAMETER DirectoryUrl
        Either the URL to an ACME server's "directory" endpoint or one of the supported short names. Currently supported short names include LE_PROD (LetsEncrypt Production v2) and LE_STAGE (LetsEncrypt Staging v2). Defaults to 'LE_PROD'.
 
    .PARAMETER DnsPlugin
        One or more DNS plugin names to use for this order's DNS challenges. If no plugin is specified, the "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 DnsPlugin 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 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 an empty string.
 
    .PARAMETER PfxPass
        Set the export password for generated PFX files. Defaults to 'poshacme'.
 
    .PARAMETER Install
        If specified, the certificate generated for this order will be imported to the local computer's Personal certificate store. Using this switch requires running the command from an elevated PowerShell session.
 
    .PARAMETER Force
        If specified, a new certificate order will always be created regardless of the status of a previous order for the same primary domain. Otherwise, the previous order still in progress will be used instead.
 
    .PARAMETER DnsSleep
        Number of seconds to wait for DNS changes to propagate before asking the ACME server to validate DNS challenges. Default is 120.
 
    .PARAMETER ValidationTimeout
        Number of seconds to wait for the ACME server to validate the challenges after asking it to do so. Default is 60. If the timeout is exceeded, an error will be thrown.
 
    .EXAMPLE
        New-PACertificate site1.example.com -AcceptTOS
 
        This is the minimum parameters needed to generate a certificate for the specified site if you haven't already setup an ACME account. It will prompt you to add the required DNS TXT record manually. Once you have an account created, you can omit the -AcceptTOS parameter.
 
    .EXAMPLE
        New-PACertificate 'site1.example.com','site2.example.com' -Contact admin@example.com
 
        Request a SAN certificate with multiple names and have notifications sent to the specified email address.
 
    .EXAMPLE
        New-PACertificate '*.example.com','example.com'
 
        Request a wildcard certificate that includes the root domain as a SAN.
 
    .EXAMPLE
        $pluginArgs = @{FBServer='fb.example.com'; FBCred=(Get-Credential)}
        PS C:\>New-PACertificate site1.example.com -DnsPlugin Flurbog -PluginArgs $pluginArgs
 
        Request a certificate using the hypothetical Flurbog DNS plugin that requires a server name and set of credentials.
 
    .EXAMPLE
        $pluginArgs = @{FBServer='fb.example.com'; FBCred=(Get-Credential)}
        PS C:\>New-PACertificate site1.example.com -DnsPlugin Flurbog -PluginArgs $pluginArgs -DnsAlias validate.alt-example.com
 
        This is the same as the previous example except that it's telling the Flurbog plugin to write to an alias domain. This only works if you have already created a CNAME record for _acme-challenge.site1.example.com that points to validate.alt-example.com.
 
    .LINK
        Project: https://github.com/rmbolger/Posh-ACME
 
    .LINK
        Submit-Renewal
 
    .LINK
        Get-DnsPlugins
 
    #>

}