Lets Encrypt Microsoft Remote Desktop via Route53

I recently got some UniFI equipment, and decided that with my USG Router capable of routing hostnames properly, it might be fun to ditch the self-signed certificates when connecting to Microsoft’s Remote Desktop service, not so much from a security perspective (as there’s nothing wrong with self-signed certificates per-se), just from an extra step of having to accept the certificate every time on my mac.

So in this post I’m going to cover how to replace the default self-signed certificate that identifies the Remote Desktop when connecting to a Windows 10 Pro PC via a Remote Desktop connection. In particular, we’re going to use Lets Encrypt to generate the certificates for free. We’re going to use the DNS01 challenge because that’s the only one that will allow a certificate to be distributed within a private network - that is, we’re going to get the certificate issued by proving that we own the domain, rather than exposing any ports. I personally would use this method anyway, even if we were not behind a private network as I think it’s a much cleaner approach architecturally, and also in terms of security and reliability.

I’ve written up this post as there was a lot of fragmented information on the different aspects of this use case but no single robust script to solve the problem. There was an example of:

  • how to import non-LE 3rd party certificates into Remote Desktop.
  • how to generate lets encrypt certificates.
  • How to import certificates into Remote Desktop Services (Not Remote Desktop)
  • How to manually import a certificate on windows 10.
  • Working with PoshACME to produce a certificate.
  • Integrating with Route53

By writing this post I am cherry picking the best parts to allow you to install a letencrypt certificate via Route53 to replace the self-signed certificate on your Remote Desktop connections to Windows 10 Pro hosts.

Summary

Here’s what we’re going to do to achieve this:

  • Install the PowerShell modules we need, and adjust the execution policy so we can run scripts.
  • Make use of Posh-ACME and the Route53 API to acquire a certificate for our target machine mydesktop.lan.mydomain.com
  • Install that certificate in our target machine’s Certificate repository.
  • Make Microsoft Remote Desktop use that certificate.

Security

Before we go any further, a word on security. This method relies on supplying AWS credentials that effectively allow the creation and destruction of DNS records, so it is vital to follow security best practices by:

  • Create a separate non-root IAM identity to access the Route53 API.
  • Properly define the policy that provides the various permissions.
  • Keeping these permissions to a minimum
  • Not leaving the credentials in an unsecured location.
  • Not being lazy and re-using the credentials for other projects.

Assumptions

I’m going to assume a certain competence with AWS and in particular the creation of IAM users, Route53, Policy creation, and knowledge of how DNS works and the various record types in particular TXT and CNAME.

I’m also going to assume you already have a domain, and have already created a zone in Route53 with an A Record. This domain must be real and you must actually have ownership such that DNS records resolve correctly via Route53.

I’m also going to assume you have a router with a local subdomain (which isn’t on the public internet), for example.

  • Your domain on the public internet burrell.co This MUST exist.
  • Your subdomain for your LAN lan.burrell.co This is figment of your router’s imagination.

This means that if the hostname of the machine you want to RDP to (known henceforth as target) is mydesktop then the fqdn is mydesktop.lan.burrell.co. This should be reachable already via RDP within your LAN. This tutorial is about certificates, not hostname resolution!

The whole script

Before we go further I must take a moment to acknowledge the inspiration for this script. This script is the result of cobbling code together from other parts of the internet , and then tailoring so that it works for this particular use case - letsencrypt, route53, windows powershell, and remote desktop (as opposed to remote desktop services - which is a totally different kettle of fish!). Some parts of the original script have not aged well due backward incompatible evolution of the API over time so I’ve fixed them too.

Let’s take a look at it in its entirety and then examine the key parts.

<#
.Synopsis
   Script to Automated Certificate Renewal for Remote Desktop via Lets Encrypt and Route53.
.DESCRIPTION
   Script to Automated Certificate Renewal for Windows 10 Remote Desktop via Lets Encrypt and Route53.
   Using Lets Encrypt and Route53 (Posh-ACME, AWSPowerShell) we can automate the issuance of certificates for our 
   Remote Desktop running on Windows 10.
.EXAMPLE
   .\letsencrypt-rdp.ps1 -LEServer le_prod 
              -domain desktop-w2005.lan.example.com
              -challengeDomain desktop-w2005.lan.example.com
              -contact daniel@example.com
              -r53secret (Get-Content secretkey.txt)
              -r53key AKIABC123

Run this script once you've configured a CNAME record _acme-challenge.desktop-w2005.lan.[example.com] -> desktop-w2005.lan.example.com

You can separately automate the creation and destruction of that cname record but if not you only create it once.

#>

param(
    [string]$LEServer,
    [string]$matchDomain,
    [string]$domain,
    [string]$challengeDomain,
    [string]$contact,
    [string]$r53Secret,
    [string]$r53Key,
    [integer]$delay = 60
)

$cdomain = "cn",$domain -join "="
$date = [datetime]::Now
$expires = $date.addHours(48)
$cert = Invoke-Command -ScriptBlock {$cdomain = $args[0];get-childitem cert:\localmachine\my | where { $_.Subject -eq $cdomain} } -ArgumentList $cdomain
$thumbprint = $cert.Thumbprint
if(($cert.NotAfter) -le $expires)
{
    # If Certificate due to expire in 48 hours, request new certificate and install to RDS.
    Write-Output "Certificate Requires Replacement"
    # Get the Certificate
    Set-PAServer $LESERVER
    if(($LESERVER) -eq "LE_STAGE")
    {
        $certName = "ACME-STAGE"
    }
    if(($LESERVER) -eq "LE_PROD")
    {
        $certName = "ACME-PROD"
    }
    $SecurePassword =  $r53Secret | ConvertTo-SecureString -AsPlainText -Force
    $r53Params = @{R53AccessKey=$r53Key; R53SecretKey=$SecurePassword}
    try{
        add-type -AssemblyName System.Web
        $randomPassword = [System.Web.Security.Membership]::GeneratePassword(15,2) | ConvertTo-SecureString -AsPlainText -Force 
        $certificate = New-PACertificate $domain -PfxPass $randomPassword -AcceptTOS -Contact $contact -DnsPlugin Route53 -DnsAlias $challengeDomain -PluginArgs $r53Params -Verbose -force -ErrorAction Stop
        Start-Sleep $delay

        Write-Output "Import Certificate"
        certutil -v -p $randomPassword -importPFX $certificate.pfxFile noExport
        
        Write-Output "Install imported certificate"
        $tp = (ls cert:\localmachine\my | WHERE {$_.Subject -match $matchDomain } | Select -First 1).Thumbprint
        wmic /namespace:\\root\CIMV2\TerminalServices PATH Win32_TSGeneralSetting Set SSLCertificateSHA1Hash="$tp" 
    }
    catch{
        $_.exception.message
    }
}
else
{
    Write-Output "Certificate does not need replacing"
    $expiry = $cert.NotAfter
    Write-Output "Expires : $expiry"
}
Get-PsSession | Remove-PSSession

We can see the main structure comes from Robert Pearman’s guide for RDS; I’ve trimmed a few unnecessary params and replaced the RDS section with the code for importing into RD instead.

Let’s break the script down into its key parts.

Configure Powershell.

  1. Start an Administrator PowerShell session on the target machine.
  2. Ensure PowerShell’s execution policy allows you to execute scripts.
  3. Looking at the GitHub repo danielburrell/rds-certs Lets execute the prerequisites.ps1 script shown below to configure the target machine with the necessary PowerShell modules.

    prerequisites.ps1

     Install-Module -Name Posh-ACME -RequiredVersion 3.6.0
     Install-Module -Name AWSPowerShell -RequiredVersion 3.3.563.1
    

Setup the CNAME record

As a one time operation we must create a CNAME record _acme-challenge.mydesktop.lan.example.com which points to mydesktop.lan.example.com. Remember that when creating a CNAME in route53, the suffix .example.com is only needed for the value, so for the key _acme-challenge.mydesktop.lan is fine.

Ideally our script would create this via the API and actually delete it too (since it’s not nice to leave details of our network as a public record).

Acquire the certificate

The follow snippet creates a LetsEncrypt certificate by fiddling with Route53 DNS records. Specifically it creates a TXT record for the domain. It then visits the challenge URL which must resolve to our TXT record.

We start by generating a password using the System.Web.Security.Membership password generator (the add-type command loads the assembly, so we can use this library). We convert the plaintext value into a SecureString so the New-PACertificate cmdlet accepts it.

  add-type -AssemblyName System.Web
  $randomPassword = [System.Web.Security.Membership]::GeneratePassword(15,2) | ConvertTo-SecureString -AsPlainText -Force 
  $certificate = New-PACertificate $domain -PfxPass $randomPassword -AcceptTOS -Contact $contact -DnsPlugin Route53 -DnsAlias $challengeDomain -PluginArgs $r53Params -Verbose -force -ErrorAction Stop

Import the certificate

This section of the script uses certutil to import the newly created certificate into the target machine’s certificate store. Note we need to pass the password we generated earlier in order to read the pfx content.

  certutil -v -p $randomPassword -importPFX $certificate.pfxFile noExport

Tell Remote Desktop to use this certificate

This section of the script tells Microsoft’s Remote Desktop to use the given certificate.

  $tp = (ls cert:\localmachine\my | WHERE {$_.Subject -match $matchDomain } | Select -First 1).Thumbprint
  wmic /namespace:\\root\CIMV2\TerminalServices PATH Win32_TSGeneralSetting Set SSLCertificateSHA1Hash="$tp"

Run this script periodically

Lets Encrypt certificates are designed to expire after 90 days as a matter of security and automation best practice. Our script ensures that we don’t bother the Lets Encrypt servers unless it’s time to renew, so we can simply run our script daily in the background to ensure the certificates are rotated. Here’s how to do this programmatically.


 $Time=New-ScheduledTaskTrigger -At 1.00PM -Once
 
 $Action=New-ScheduledTaskAction -Execute PowerShell.exe -WorkingDirectory C:/Scripts `
        -Argument letsencrypt-rdp.ps1 `
                  -LEServer le_prod `
                  -domain desktop-w2005.lan.example.com `
                  -challengeDomain desktop-w2005.lan.example.com `
                  -contact daniel@example.com `
                  -r53secret xxxxxxx `
                  -r53key AKIABC123
 
 Register-ScheduledTask -TaskName "Schedule MFA Status Report" -Trigger $Time -Action $Action -RunLevel Highest

The above code creates a new task trigger every day at 4pm, defines a script to be called, and finally binds the task to the action so it runs.

Packaging it all up

You can download the above as an installable zip from the GitHub repo danielburrell/rds-certs.