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.
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
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.
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
Per the documentation for ACMESharp, SANs can be generated with New-Certificate:
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.
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!!!
Thanks for reading! I don’t see an obvious parameter to control the key size for the certificate. That may be a question to ask of the ACMESharp developers.
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.
That script reference is a source line reference in ACMESharp, so don’t worry about that.
The error itself looks like it could be related to the following StackOverflow discussion: http://stackoverflow.com/questions/55411/path-gettempfilename-directory-name-is-invalid
Good luck! 🙂
@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.
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.
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
)
Thank you for the great script!!
You’re welcome! 🙂
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.
You are right, that’s a bug… It should be
if($certinfo.SerialNumber -eq "") {
Fixing that now! Thank you 🙂
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
Nice! Thanks for the contribution!
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
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”
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
That sounds like you have hit a rate limit on the Let’s Encrypt servers — see https://letsencrypt.org/docs/rate-limits/ for details.
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.
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.
Thanks for reading! It seems likely that the issue is related to https://github.com/PKISharp/win-acme/issues/550#issuecomment-328317837 but I am not sure. There are a few links on that page which may provide more information.
See the discussion at https://stackoverflow.com/questions/20048486/http-error-500-19-and-error-code-0x80070021 for more knowledge about the error message you received.
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!
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/
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
Great catch, thank you 🙂 In an airport right now but will update post shortly.
Hi Marc,
Great script!
I’m getting following error when run script:
Vault root path does not contain vault data… Any hint?
Regards,
edge
Thanks for reading! No specific ideas on that error. https://github.com/ebekker/ACMESharp/issues/202 may help?
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).
Hi Marc, thanks for the script, have been using it for years.
I recently modified it to call the ACME-PS module that works with the ACME v2 protocol, so it still works for new domains even today.
Here is the ACME-PS modified version:
https://gist.github.com/kovachwt/016576aa87383b05ac0ceda18fd2fc4b
That’s awesome 🙂 Thank you for sharing!