Let's Encrypt, on Windows

Note: this post is over a year old, it's very likely completely outdated and should probably not be used as reference any more. You have been warned. :-)

I've been learning a lot about security and https in the last year or so and acquiring a certificate has always been a bit of a drag. Sure, DNSimple has made it pretty easy and affordable but it's still $20 a year for something that is 100% automated, all they have to do is keep their systems working and reap the benefits.

 

Update: this is getting much easier already, check out the follow up post to this one.

 

So I was very excited to learn about a year ago about the formation of Let's Encrypt. As they describe it: "Anyone who owns a domain name can use Let’s Encrypt to obtain a trusted certificate at zero cost.".

Awesome! They have some good sponsors and I'm donor for the Electronic Fronteer Foundation, which started this initiative so in a way, I'm still paying for my certificates, sort of. ;-)

I was super excited this week to see that Let's Encrypt was in public beta now and wanted to play with it! But.. of course all of the official tooling focuses on Linux environments and it's been a while for me since I used that. So, I found a cool little project on GitHub called ACMESharp, implementing the protocol and APIs used by Let's Encrypt using PowerShell.

It took me a while to wrap my head around it but it all makes sense now after playing with it for a while, there's really only 3 steps:

  1. You have to agree to the terms of service
  2. You have to prove that you own the domain that you want a certificate for
  3. You request the certificate

The documentation for ACMESharp gave me some clues as to how this works but there was still a few "gotcha" moments there. I'll lead you through the PowerShell script that I came up with:

Import-Module -Name D:\Temp\ACME-posh\ACMEPowerShell.psd1
$domain = "cork.nl"
$certificiatePassword = "abcd1234"
$email = "[email protected]"
$vault = "D:\Vault\{0}\{1}" -f $domain, [guid]::NewGuid()
mkdir $vault
cd $vault

Some setup cruft - first of all, you download the ACMESharp release that's available and unzip it somewhere and import the module you find in there. I make a new folder for each attempt at running this script by generating a new GUID each time.

Next up: pointing to the public beta server that will give you a real, trusted certificate. At first the URL I had here was still pointing at the staging server which gives you cerificates issued by "happy hacker fake CA". Needless to say: I can't use those in production.

Initialize-ACMEVault -BaseURI https://acme-v01.api.letsencrypt.org/
New-ACMERegistration -Contacts mailto:$email
Update-ACMERegistration -AcceptTOS

The terms of service are accepted here and I give them my email address, this was a little confusing as I was waiting for an email to come in to click the "Accept TOS" link but it didn't come. Good, because this is now fully automated, I can just tell them in PowerShell that I accept the terms of service, which I obviously studied closely (not!).

Okay, now the fun part starts: verifying that you own the domain. You ask Let's encrypt to give you a secret randomly generated blob of text that you, as the site owner can put on your website.

New-ACMEIdentifier -Dns $domain -Alias dns1
New-ACMEProviderConfig -WebServerProvider Manual -Alias manualHttpProvider -FilePath $vault\answer.txt
Get-ACMEIdentifier -Ref dns1
$completedChallenge = Complete-ACMEChallenge -Ref dns1 -Challenge http-01 -ProviderConfig manualHttpProvider

Note: the FilePath variable in New-ACMEProviderConfig doesn't exist in the 0.7.1 release. I created a pull request to add that to ACMESharp and it was accepted an hour later (yay!). If you use this right now, you'll get an instance of Notepad popping up asking you to complete the FilePath in the bit of JSON that gets put into Notepad. I like automation so I asked if it could just be a variable. 

Now, the Complete-ACMEChallenge command returns all the information you need to complete the challenge, I didn't read this properly at first so I wasn't sure what to do. I did it wrong and then the challenge status was set to 'invalid'. Even after correcting my mistake and following the instructions, it was still 'invalid'. "Damn it! Now I'll never be able to get my certificate..". Or so I thought. After Googling for a bit I found out that once a challenge was marked as invalid once, it would never become valid (there's good security reasons behind this, preventing replay attacks). So the trick is to ask for a new challenge, easy!

I added some output to my PowerShell script to remind me exactly what to do:

$challengeAnswer = ($completedChallenge.Challenges | Where-Object { $_.Type -eq "http-01" }).ChallengeAnswer
$key = $challengeAnswer.Key
Write-Host ""
Write-Host "Create folder structure on $domain like so:"
Write-Host "$domain/$key"
Write-Host "Put an index.html file in that location that contains:"
Write-Host $challengeAnswer.Value

Which outputs something like this:

Create folder structure on cork.nl like so:
cork.nl/.well-known/acme-challenge/V6CPYDViDCk6X3YWC9wH61kKW2CHtQ-SLACnIcBNFPY
Put an index.html file in that location that contains:
V6CPYDViDCk6X3YWC9wH61kKW2CHtQ-SLACnIcBNFPY.8uMzTUtlJpLEsyNHnTmLutOPZyFv4VUCFwaqram0gRo

I can do that! So what needs to happen is when the Let's Encrypt server goes to the URL http://cork.nl/.well-known/acme-challenge/V6CPYDViDCk6X3YWC9wH61kKW2CHtQ-SLACnIcBNFPY the body of the response should contain the secret key: V6CPYDViDCk6X3YWC9wH61kKW2CHtQ-SLACnIcBNFPY.8uMzTUtlJpLEsyNHnTmLutOPZyFv4VUCFwaqram0gRo

In this case I do this by placing an index.html in the created folder as I know that IIS will serve that up by default, your server might be configured differently so make sure that it outputs the secret key somehow (and only the secret key, nothing else). 

Right! Now we can tell Let's Encrypt: I'm ready, please verify:

$challenge = Submit-ACMEChallenge -Ref dns1 -Challenge http-01
While ($challenge.Status -eq "pending") {
Start-Sleep -m 500 # wait half a second before trying
Write-Host "Status is still 'pending', waiting for it to change..."
$challenge = Update-ACMEIdentifier -Ref dns1
}

Here the challenge is submitted and it might take a second for the status to change from "pending" to "valid" so I ask for an update every half second before continuing.

When the status is "valid" we're golden, go get that cert now!

New-ACMECertificate -Identifier dns1 -Alias cert1 -Generate
$certificateInfo = Submit-ACMECertificate -Ref cert1

While([string]::IsNullOrEmpty($certificateInfo.IssuerSerialNumber)) {
Start-Sleep -m 500 # wait half a second before trying
Write-Host "IssuerSerialNumber is not set yet, waiting for it to be populated..."
$certificateInfo = Update-ACMECertificate -Ref cert1
}

Get-ACMECertificate -Ref cert1 -ExportPkcs12 cert1-all.pfx -CertificatePassword $certificiatePassword

Write-Host "All done, there's a cert1-all.pfx file in $vault with password $certificiatePassword for you to use now"

We're asking Let's Encrypt to generate the certificate and then we do another few pings for the IssuerSerialNumber to update, that's when the certificate has been completely generated. Finally we do a Get-ACMECertificate to receive it and it will be stored in the $vault folder. Note: I'm adding a password to this certificate but this functionality is not yet available in the 0.7.1 release of ACMESharp. Again, this was a specific need that I had for which I sent a pull request which was promptly accepted. 

If all this sounds like a lot of work: it is not! 

Now that you understand what's going on, this process is repeatable. Which is very necessary because by default these certificates expire every 90 days and will expire even more quickly in the future. This is being done to promote security: a compromised certificate can now only be abused for a maximum of 90 days. It's also being done to promote automation: I built a PowerShell script so I can automate this, they achieved their goal!

The full PS script is available in a gist for you to copy and use.

Go forth and secure your sites!

Sebastiaan Janssen

Dutch guy living in (and loving) Copenhagen, working at Umbraco HQ. Lifehacker, skeptic, music lover, cyclist, developer.

 

23 comments on this article

Avatar for Stuart Quinn Stuart Quinn | December 6 2015 15:39
Really, really good that this is happening, and thanks for doing the hard work in trying this out, doing the pull requests + writing the post +PS script. Can't wait to try this out next week!

Avatar for Jeffrey Schoemaker Jeffrey Schoemaker | December 7 2015 12:32
Hi Sebas,

thanks for researching and sharing! It was on my todo-list for a long time and this makes it a lot easier.

I've heard that it's currently not available for The Netherlands? Have you heard that too?

Avatar for Sebastiaan Janssen Sebastiaan Janssen | December 7 2015 12:42
@Jeffrey I don't know, I don't see why it shouldn't be available for everybody? The certificate I got was for a .nl domain and is accepted by the browser, so shouldn't be a problem!

Avatar for Markus The Markus The | December 7 2015 16:13
Hi Sebastiaan,

Thanks for figuring this out - I had a look at ACMESharps Wiki last week but the whole process looked rather daunting to me. Glad you did the hard work ;-)

I downloaded the ACME-posh.zip release, unzipped it into c:\tools\AcmePowerShell, downloaded your GIST and put that in the same directory, but the Import-Module statement results in:

Import-Module : Could not load file or assembly 'file:///C:\Tools\ACMEPowerShell\ACMESharp.POSH.dll' or one of its dependencies. Operation is not supported. (Exception from
HRESULT: 0x80131515)At C:\Tools\ACMEPowerShell\ACME.ps1:1 char:1

Both for x86 and x64...

Get-Item C:\Tools\ACMEPowerShell\ACMESharp.POSH.dll

works, so it's not the path... Am I missing some dependency?

Avatar for Jeffrey Jeffrey | December 7 2015 16:15
@Sebastiaan: Aah, I attempted to do that when the service was still in the grace period but now it all works indeed. Let's start testing :)!

Avatar for Sebastiaan Janssen Sebastiaan Janssen | December 7 2015 16:18
@Markus Strange, haven't seen that! Would be best to report the issue on the ACMESharp GitHub.

Avatar for Sebastiaan Janssen Sebastiaan Janssen | December 7 2015 16:19
@Markus Aha, never mind.. I think you need to "Unblock" the zip (maybe even the unzipped files) you downloaded (file properties).

Avatar for Markus The Markus The | December 7 2015 16:22
I unblocked everything in the C:\Tools\AcmePowershell folder and subfolders - still no luck but maybe something's cached. I'll keep at it!

Avatar for Markus The Markus The | December 7 2015 16:27
That was it! Had to restart PowerShell IDE after unblocking everything.

Now I get a complaint about the -FilePath parameter . Doh - I'm using 0.7.1 which does not support that as you say, so I guess I'll have to build AcmeSharp from source.

BTW: I've been a big fan of Umbraco since early v4!

Avatar for Markus The Markus The | December 8 2015 10:45
Sorry to keep spamming, but I just want to confirm I successfully generated my first .sfx just now. I build AcmeSharp from source (using Visual Studio 2015, because the source uses $-strings which aren't supported in VS2013), and imported the PS-module from ACMESharp-POSH\bin\debug\ACMEPowerShell.

Just one remark: I added a Read-Host-statement in your Gist in line 34, just before the call to Submit-ACMEChallenge. This delays the call to Submit-ACMEChallenge until I've made the necessary manual changes (i.e. uploading the Index.html file to the site) and pressed Enter to resume the script. Without the pause, the status of changes from "pending" to "invalid" and you have to start over. (I know, you say this in the blog post, but I interpreted the output of the script as "it's OK to just continue and poll while you make the necessary manual changes").

Thanks again! Next step for me: automatic renewal.

Avatar for Sebastiaan Janssen Sebastiaan Janssen | December 8 2015 11:04
@Markus awesome, sounds like a good addition! Glad it's working for you now! :)

Avatar for Jeff Nall Jeff Nall | December 15 2015 04:30
I started off the night planning on blogging about how to do this, but you did a better job than I ever could. Followed your instructions and generated my cert. Great post!

Avatar for Aaron Barnett Aaron Barnett | April 15 2016 14:28
DOESN'T WORK - LATEST VERSION.
v0.8.0.0 +

First - Great post. Thanks!

Second -
The latest version is completely different (the acme commands are different, you don't specify the vault location, etc)

I've updated the script for the latest version:
https://gist.github.com/aarbar/d5685ddc3226bb287e89e7ac7b4d6219

Avatar for Charles Roper Charles Roper | April 26 2016 13:51
Great post Sebastiaan - really helpful. Thanks for the update Aaron.

A couple of questions:

1. I get a cert1-all.pfx at the end. What do I now do with this? My host needs an "SSL Server private key" and an "SSL Certificate"

2. When I want to generate a new cert, I have to clear out the existing vault - is that expected? If I don't clear it, the script fails.

3. When generating a new cert, I need to update the Resource Record (TXT) on DNS. This is a manual step - is there a way of automating it? I'm using CloudFlare for DNS which definitely can be automated: https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record. Or can this step be avoided somehow?

Avatar for Charles Roper Charles Roper | April 26 2016 18:13
Okay, I worked out what to do for #1. For the certificate, I did this:

> Get-ACMECertificate cert1 -ExportCertificatePEM "cert1.crt.pem"

For the key, this:

> Get-ACMECertificate cert1 -ExportKeyPEM cert1.key.pem

I then copied and pasted the WHOLE content of those files up to my host.

#3 also pretty easy to do (I think) using Invoke-WebRequest in PS.

So that leaves #2. When the certificate expires, do I clear out the vault and start again, or can I make use of what I've already got somehow (including the TXT DNS record)?

Avatar for Charles Roper Charles Roper | April 26 2016 21:21
I've created a script to update the _acme-challenge TXT entry on CloudFlare.

https://gist.github.com/charlesroper/38a57c7b28cfae2bfe6b9f67ab73de98

This could be built into the main script, but I don't know how to programatically grab the RR Value from the Complete-ACMEChallenge command.

Does anyone know how?

Avatar for Thomas Thomas | July 20 2016 12:54
Hi there, (lets encrypt) Can I ask you how do you issue a cert for both www and non www what is the command line you use to issue one certificate. Thanks Thomas

Avatar for Sebastiaan Janssen Sebastiaan Janssen | July 20 2016 13:42
Hey there! I'm not Let's Encrypt.. ;-)

You need two certificates, one for www and one for non-www. Let's Encrypt does not support wildcard certificates.

Avatar for Markus The Markus The | July 20 2016 14:37
But it DOES support "Subject Alternative Names" - SAN's. This allows you to have a single certificate covering www.site.com and site.com (and site2.com and site3.com, etc.)

I've used ACMESharp (https://github.com/ebekker/ACMESharp), which has a PowerShell script supporting SAN's.

Avatar for Markus The Markus The | July 20 2016 14:40
Duh - of course you used AcmeSharp too. But it now supports SAN's, so check out https://github.com/ebekker/ACMESharp/wiki/Quick-Start and look for the -AlternativeIdentifierRefs argument

Avatar for Sebastiaan Janssen Sebastiaan Janssen | July 20 2016 15:47
But SAN has to be predefined, so if you change your mind and need to add more subdomains or additional domains, you'd still need to regenerate the cert.

And if you ever need to revoke the certificate, you'd also revoke it for the other domain names.

It's literally free to generate multiple certificates for a domain so I'd personally not bother with SAN.

Avatar for Markus The Markus The | July 20 2016 17:06
I agree, but I'm still running on IIS 7(.5?) and there's no support for multiple SSL-certificates on the same IP-address/port.

Besides, the original question was for www and non-www and that is IMHO a classic example of the CORRECT use of a SAN certificate!

Avatar for Crispin Wright Crispin Wright | July 24 2016 22:59
Thanks for doing all the heavy lifting here Sebastiaan, i'l be putting all this together over the next few days. shout if you're ever in new zealand, i'll buy you a beer.