Let’s Encrypt on Windows, redux

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.

 

34 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.

    1. @Pete, did you ever find a solution to this? I’m having the same problem. If I submit the commands manually in a PS box it’s fine. If I run it as a script, I get this same error.

      1. Actually, for me, I had the wrong path for the key storage! Once I actually had it pointing at an existing folder it was fine.

  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
    )

  6. Marc, did you ever received a message with text “We did not receive a completed certificate after 60 seconds”? In the code above, the $i is only incremented when SerialNumber has a value, so it will loop forever when there is no SerialNumber? I’m a Powershell newbie, so could be I am wrong. Thanks for the post, very helpful.

    1. You are right, that’s a bug… It should be if($certinfo.SerialNumber -eq "") {

      Fixing that now! Thank you 🙂

  7. Hi, thank you for your work – it works great 🙂

    I have made a little extention to write the log that is send by mail to the eventlog:
    Just add this line after the following lines
    # Finished
    Write-Log “Finished”
    $Body = $EmailLog | out-string

    Write-EventLog -LogName Application -Source “LetsEncryptRenew” -EntryType Information -EventID 49999 -Message $Body

    and this in the catch

    Write-EventLog -LogName Application -Source “LetsEncryptRenew” -EntryType Error -EventID 49999 -Message $Body

    This will only work if you generate an Event Source “LetsEncryptRenew”
    For that you have to run

    New-EventLog –LogName Application –Source “LetsEncryptRenew”

    one time on the computer

    BR
    Wolfram

  8. My memory was a little fuzzy when it came to setting the right parameters, but I was able to get it to work. Here are the two things that tripped me up initially.

    $domain = “my.example.com” #This should be the site you want to apply the SSL cert on, and it needs to be reachable externally

    $iissitename = “my.example.com” #This needs to be the name of the website in IIS. So if the site is “Default Web Site”, this is the name you need to enter.

    I also had to specify an smtp server in order for the email portion to work. For those of you who run into a similar situation use -Smtpserver ‘smtp.domain.com’. The smtp server address needs to be enclosed in single quotes.

    Michael

  9. I’m finding on some machines that I have to add a “-force” parameter to Complete-ACMEChallenge and Submit-ACMEChallenge. Haven’t investigated why at this point but it might help you if you are stuck with the error “Submitting the challenge
    System.InvalidOperationException: authorization is not in pending state; use Force flag to override this validation”

  10. Hi Marc,

    Thank you for sharing this script, however it’s works fine for me the first time then I get this error message

    ACMESharp.AcmeClient+AcmeWebException: Unexpected error —> System.Net.WebException: The remote server returned an error: (429) Too Many Requests.
    at System.Net.HttpWebRequest.GetResponse()
    at ACMESharp.AcmeClient.RequestHttpPost(Uri uri, Object message) in C:\projects\acmesharp\ACMESharp\ACMESharp\AcmeClient.cs:line 700
    — End of inner exception stack trace —
    at ACMESharp.AcmeClient.RequestCertificate(String csrContent) in C:\projects\acmesharp\ACMESharp\ACMESharp\AcmeClient.cs:line 569
    at ACMESharp.POSH.SubmitCertificate.ProcessRecord() in C:\projects\acmesharp\ACMESharp\ACMESharp.POSH\SubmitCertificate.cs:line 155

    Could you please help.

    Best regards

  11. Our Web sites are multi-tenancy, so we have multiple https bindings with different domain names on one IIS site. The script as it is will overwrite all https bindings on one site with the last certificate created for that site. Eg, site1.com, site2.com, site3.com all share a single IIS site. All three will be bound with https://site3.com certificate.

    I updated your script to make sure it only binding to a matching domain by changing this line:
    if($binding.protocol -eq “https”) {
    to:
    if($binding.protocol -eq “https” -and $binding.bindingInformation -like “*$domain*”) {

    I hope that helps someone else. Thanks so much for your script.

  12. Hello, Mark!
    I tried your script and it is the message:
    “Generating a new identifier for test.example.com Completing a challenge via http Submitting the challenge We did not receive a completed identifier after 60 seconds”
    Please, prompt what is incorrect?
    When I try open page http://test.exemple.com/.well-known/acme-challenge – it write error in IE: “HTTP Error 500.19 – Internal Server Error, Error Code 0x80070021. Config Source: … 7: .”
    Thank you for help.

  13. Hi Mark and thanks for this script!

    I’ve got an issue because AcmeSharp has been updated and from version 0.9.0 onwards the management of the Vault and the VaultProfile must be changed (the Encrypting File System support has been enabled) https://pkisharp.github.io/ACMESharp-docs/Local-Vault-EFS

    Now when I run the script it stops during “Generating certificate for domain” with this error:

    System.UnauthorizedAccessException: Access to the path ‘C:\ProgramData\ACMESharp\sysVault \45-KEYPM\[myref]-key.pem ‘ is denied.

    Is there a way to disable EFS or set a custom VaultProfile and then a different folder?

    Thanks!

    1. Thank you for sharing — I was not aware of this issue. From my reading, the problem arises if your system does not have EFS enabled. It does sound like you should be able to get things working without EFS by following the instructions on the page you linked to create a custom vault profile.

      PS> Set-ACMEVaultProfile -ProfileName my-vault -Provider local -VaultParameters @{ RootPath = "$env:LocalAppData\MyAcmeVault"; CreatePath = $true; BypassEFS = $true }
      PS> Initialize-ACMEVault -VaultProfile my-vault

      You would need to modify the script to reference your custom vault profile everywhere, or you may be able to use the environment variable ACMESHARP_VAULT_PROFILE

      I would recommend testing with the staging environment, as I describe in https://marc.durdin.net/2016/11/automating-certificate-renewal-with-lets-encrypt-and-acmesharp-on-windows/

  14. Hi Marc great job!

    I would recommend on each line (4x) starts with Send-MailMessage to add “-SmtpServer $PSEmailServer” at the end.
    Otherweise $PSEmailServer variable on top is useless.

    Best regards, Roger

  15. Hi Marc,

    Great script!

    I’m getting following error when run script:
    Vault root path does not contain vault data… Any hint?

    Regards,
    edge

  16. On the HTTP vs HTTPS. Rather than force you to add HTTP to an HTTPS only site, you can use a different IIS site for the challenge (you use one site to respond to the challenge, and one where you install the certificate).

    Add a variable for the challenge site (by default, the same as the encrypted site)
    $iissitenamechllng = “my.example.com”

    Change the site responding to the challenge
    Complete-ACMEChallenge $certname -ChallengeType http-01 -Handler iis -HandlerParameters @{ WebSiteRef = $iissitenamechllng }

    That way you don’t have to mess around with the configuration of an existing HTTPs only site that might not belong to you (ie, you’re just managing its certificate, but you don’t want to take ownership of the site by modifying its configuration, and avoid any unintended consequences when changing its web.config – for example, Exchange, RDSGW sites). And this challenge scheme can be used with multiple HTTPS only sites (as long as their DNS resolution and HTTP binding default to the challenge site).

Leave a Reply

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