Jan 2020, please note: This approach is now deprecated. Let’s Encrypt will stop accepting ACMEv1 requests in June 2020. Have a look at https://letsencrypt.org/docs/client-options/ for alternatives to this process.
A couple of months ago I wrote a script to automatically renew Let’s Encrypt certificates in PowerShell on Windows. The renewal process works really well, however, there is one wrinkle that I did not cover. In this blog post I smooth out that wrinkle!
The wrinkle
When it came time to renew my certificate, a few days ago (well within the 90 day limit for the certificate, mind you), I discovered that the identifier for the certificate had expired. Why was this a problem? Well, I had originally used a manual DNS challenge to validate the identifier with Let’s Encrypt. This worked fine, but of course, manually creating a new identifier and challenge every 90 days completely undoes the benefit of automatic certificate renewal.
Smoothing out the wrinkle
In order to resolve this, I needed to automatically generate and validate an identifier at the same time as I generated a new certificate. The only automatic challenge provider at this time with ACMESharp is the http-01 provider with the IIS handler.
So I updated the script to generate the identifier automatically and validate with the http-01 challenge provider. However, the http-01 challenge provider requires a HTTP request, not a HTTPS request, to the hostname in question, because, and I quote the IETF draft here:
… Because many webservers allocate a default HTTPS virtual host to a particular low-privilege tenant user in a subtle and non-intuitive manner, the challenge must be completed over HTTP, not HTTPS.
I love subtle and non-intuitive computing!
But this was a problem for me, because the secure site I was working on did not have a http endpoint, only a https endpoint, which is the whole reason I used a DNS challenge in the first place.
I finally threw in the towel and I decided to setup a http endpoint, and configure automatic redirection to https (yes, I could go further here). You may find you need to setup an exception for automatic redirection for the .well-known/ root folder (where http challenges are kept as static files). I’ll leave that tweak for you to figure out (it’s just another line or two in your site root web.config).
A deeper wrinkle
The wrinkles get a little deeper, when you look at what happens if you already have a valid challenge response for an identifier. This could happen if you had manually validated another alias for the same identifier using e.g. dns, as I had in the past, or if you are renewing before your existing identifier expires. Because the challenge responses are matched to the hostname, and not the aliases, the previously valid responses continue to be acceptable. In this situation, the existing challenge response was used by the Let’s Encrypt server, and it never checked my shiny new web server challenge endpoint. This meant we needed to see if any existing challenge responses were considered valid, rather than relying on checking the challenge we’d just setup. The script changes handle this scenario.
Just read me the script
The full PowerShell script is shown below; the changes begin at the start of the Try
block, and finish after the New-ACMECertificate
call. More detail on how the script works is provided in the previous blog post.
import-module ACMESharp
#
# Script parameters
#
$domain = "my.example.com"
$iissitename = "my.example.com"
$certname = "my.example.com-$(get-date -format yyyy-MM-dd--HH-mm)"
#
# Environmental variables
#
$PSEmailServer = "localhost"
$LocalEmailAddress = "[email protected]"
$OwnerEmailAddress = "[email protected]"
$pfxfile = "c:\Admin\Certs\$certname.pfx"
$CertificatePassword = "PASSWORD!"
#
# Script setup - should be no need to change things below this point
#
$ErrorActionPreference = "Stop"
$EmailLog = @()
#
# Utility functions
#
function Write-Log {
Write-Host $args[0]
$script:EmailLog += $args[0]
}
Try {
Write-Log "Generating a new identifier for $domain"
New-ACMEIdentifier -Dns $domain -Alias $certname
Write-Log "Completing a challenge via http"
Complete-ACMEChallenge $certname -ChallengeType http-01 -Handler iis -HandlerParameters @{ WebSiteRef = $iissitename }
Write-Log "Submitting the challenge"
Submit-ACMEChallenge $certname -ChallengeType http-01
# Check the status of the identifier every 6 seconds until we have an answer; fail after a minute
$i = 0
do {
$identinfo = (Update-ACMEIdentifier $certname -ChallengeType http-01).Challenges | Where-Object {$_.Status -eq "valid"}
if($identinfo -eq $null) {
Start-Sleep 6
$i++
}
} until($identinfo -ne $null -or $i -gt 10)
if($identinfo -eq $null) {
Write-Log "We did not receive a completed identifier after 60 seconds"
$Body = $EmailLog | out-string
Send-MailMessage -SmtpServer $PSEmailServer -From $LocalEmailAddress -To $OwnerEmailAddress -Subject "Attempting to renew Let's Encrypt certificate for $domain" -Body $Body
Exit
}
# We now have a new identifier... so, let's create a certificate
Write-Log "Attempting to renew Let's Encrypt certificate for $domain"
# Generate a certificate
Write-Log "Generating certificate for $domain"
New-ACMECertificate $certname -Generate -Alias $certname
# Submit the certificate
Submit-ACMECertificate $certname
# Check the status of the certificate every 6 seconds until we have an answer; fail after a minute
$i = 0
do {
$certinfo = Update-AcmeCertificate $certname
if($certinfo.SerialNumber -eq "") {
Start-Sleep 6
$i++
}
} until($certinfo.SerialNumber -ne "" -or $i -gt 10)
if($i -gt 10) {
Write-Log "We did not receive a completed certificate after 60 seconds"
$Body = $EmailLog | out-string
Send-MailMessage -SmtpServer $PSEmailServer -From $LocalEmailAddress -To $OwnerEmailAddress -Subject "Attempting to renew Let's Encrypt certificate for $domain" -Body $Body
Exit
}
# Export Certificate to PFX file
Get-ACMECertificate $certname -ExportPkcs12 $pfxfile -CertificatePassword $CertificatePassword
# Import the certificate to the local machine certificate store
Write-Log "Import pfx certificate $pfxfile"
$certRootStore = "LocalMachine"
$certStore = "My"
$pfx = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2
$pfx.Import($pfxfile,$CertificatePassword,"Exportable,PersistKeySet,MachineKeySet")
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store($certStore,$certRootStore)
$store.Open('ReadWrite')
$store.Add($pfx)
$store.Close()
$certThumbprint = $pfx.Thumbprint
# Bind the certificate to the requested IIS site (all https bindings)
Write-Log "Bind certificate with Thumbprint $certThumbprint"
$obj = get-webconfiguration "//sites/site[@name='$iissitename']"
for($i = 0; $i -lt $obj.bindings.Collection.Length; $i++) {
$binding = $obj.bindings.Collection[$i]
if($binding.protocol -eq "https") {
$method = $binding.Methods["AddSslCertificate"]
$methodInstance = $method.CreateInstance()
$methodInstance.Input.SetAttributeValue("certificateHash", $certThumbprint)
$methodInstance.Input.SetAttributeValue("certificateStoreName", $certStore)
$methodInstance.Execute()
}
}
# Remove expired LetsEncrypt certificates for this domain
Write-Log "Remove old certificates"
$certRootStore = "LocalMachine"
$certStore = "My"
$date = Get-Date
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store($certStore,$certRootStore)
$store.Open('ReadWrite')
foreach($cert in $store.Certificates) {
if($cert.Subject -eq "CN=$domain" -And $cert.Issuer.Contains("Let's Encrypt") -And $cert.Thumbprint -ne $certThumbprint) {
Write-Log "Removing certificate $($cert.Thumbprint)"
$store.Remove($cert)
}
}
$store.Close()
# Finished
Write-Log "Finished"
$Body = $EmailLog | out-string
Send-MailMessage -SmtpServer $PSEmailServer -From $LocalEmailAddress -To $OwnerEmailAddress -Subject "Let's Encrypt certificate renewed for $domain" -Body $Body
} Catch {
Write-Host $_.Exception
$ErrorMessage = $_.Exception | format-list -force | out-string
$EmailLog += "Let's Encrypt certificate renewal for $domain failed with exception`n$ErrorMessage`r`n`r`n"
$Body = $EmailLog | Out-String
Send-MailMessage -SmtpServer $PSEmailServer -From $LocalEmailAddress -To $OwnerEmailAddress -Subject "Let's Encrypt certificate renewal for $domain failed with exception" -Body $Body
Exit
}
Side note: I discovered it’s important to let ACMESharp do its thing in the script and not try and do anything with it in another process because it tends to fall over with an exception if some other process is accessing its vault when it wants to.
Another minor update (18 Feb 2017): my $alias
in my live script happened to be the same as my $domain
; if they differed, then the script would fail. The $alias
variable is no longer needed and has been removed from the script above. Thank you to BdN3504 for reporting this in the comments!
Yet another update (15 Apr 2018): The $PSEmailServer
variable in my script was defined but never used. Send-MailMessage
calls updated to use it with -SmtpServer
. Thank you to Roger for reporting.