Let’s Encrypt on Windows, redux

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 -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 -ne "") {
      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 -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 -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 -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!

11 thoughts on “Let’s Encrypt on Windows, redux

  1. I tried using your script and came across this error:

    New-ACMEIdentifier : DNS name does not have enough labels
    At line:1 char:1
    + New-ACMEIdentifier -Dns old-alias -Alias new-alias
    + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo : InvalidData: (ACMESharp.Vault.Model.IdentifierInfo:IdentifierInfo) [New-ACMEIdentifier],
    AcmeWebException
    + FullyQualifiedErrorId : urn:acme:error:malformed (400),ACMESharp.POSH.NewIdentifier

    I fixed the error by changing this line:
    Try {
    Write-Log “Generating a new identifier for $alias”
    New-ACMEIdentifier -Dns $alias -Alias $certname

    to the following:
    Try {
    Write-Log “Generating a new identifier for $alias”
    New-ACMEIdentifier -Dns $domain -Alias $certname

    1. Thank you — I’ve fixed the bug; it did not emerge in my live script because my $alias and $domain happened to be the same value.

  2. Hi Marc,

    thanks for your work. I’m tried to modify your script to use it with my mail server, no success. As far as I can see there are two things different in my environment:

    1. I need several subject alternative names for my certificate
    2. Binding section of the script should be different, since I don’t run a web server

    Do you think you could add the necessary lines for that goal? Would be great!

    Thanks and regards,
    Stefano

    1. Per the documentation for ACMESharp, SANs can be generated with New-Certificate:

      Subject Alternative Names (SAN)
      If you want to generate a CSR that lists multiple names, you can use the Subject Alternative Names extension of the PKI certificate request to list multiple additional names other than the primary Subject Name. To do so you specify the -AlternativeIdentifierRefs option with a list of one or more additional Identifier references. Each Identifier reference listed should be validated as outlined in steps 4, 5 and 6.
      PS> New-ACMECertificate dns1 -Generate -AlternativeIdentifierRefs dns2,dns3,dns4 -Alias multiNameCert
      PS> Submit-ACMECertificate multiNameCert

      However, without a web server, you won’t be able to automatically handle the domain ownership challenge, which means you’ll need to manually verify ownership via DNS every time you renew the identifier, because the identifier expires after 42 days. The script in my earlier blog post may be more helpful for you in that instance.

  3. First time that i created a ssl certificate. Your PowerShell script worked great, but i was wondering if there was any way to use your script for a 4096 bit encryption strength instead of the default 2048. I understand that LetsEncrypt supports this but being a total noob at this i have no idea how to implement this! Thanks!!!

  4. I successfully got a cert from LE on this server by following ebekker’s instructions, but the cert has since expired..

    I’m close, so close! I tried your updated script and got this:

    Message : The directory name is invalid.

    Data : {}
    InnerException :
    TargetSite : Void WinIOError(Int32, System.String)
    StackTrace : at System.IO.__Error.WinIOError(Int32 errorCode, String
    maybeFullPath)
    at System.IO.Path.InternalGetTempFileName(Boolean
    checkHost)
    at ACMESharp.POSH.UpdateCertificate.ProcessRecord() in C:\p
    rojects\acmesharp\ACMESharp\ACMESharp.POSH\UpdateCertificate.c
    s:line 207
    at
    System.Management.Automation.CommandProcessor.ProcessRecord()
    HelpLink :
    Source : mscorlib
    HResult : -2147024629

    I have no idea what this means! It appears to be referring to a script that doesn’t exist on that location on my server.

  5. I added this to the top of the script and commented out the variables so that I can use the parameters in scheduled tasks to update a few certificates I use for development domains – thought I’d share

    [CmdletBinding()]
    Param(
    [Parameter(Mandatory=$true,Position=1)]
    [string]$domain,

    [Parameter(Mandatory=$true)]
    [string]$iissitename,

    [Parameter(Mandatory=$true)]
    [string]$certname
    )

Leave a Reply

Your email address will not be published. Required fields are marked *