Securing RDP with Let's Encrypt

I've been working on an internal service backed by Let's Encrypt, allowing any number of endpoints to add, delete, or download certificates for their own purposes, while offloading certificate renewal to the internal service itself. This has allowed for easy SSL certificates everywhere, without needing to deploy and configure certbot on every endpoint.

The following were considerations I encountered while securing RDP on a Windows 11 Virtual Machine from this internal service. Some lessons and commands were picked up from Microsoft Tech Community Documentation about this topic.


Let's Encrypt Certificates must be PFX

Windows requires certificates be imported to the certificate store in a .pfx format. The native output of certificates from certbot are in .pem format. While converting this to .pfx can be done via openssl, I opted for a python implementation, as this is what the backend is running. Below is the python function that does this conversion:

from cryptography import x509
from cryptography.hazmat.primitives.serialization import BestAvailableEncryption, load_pem_private_key, pkcs12

class CertificateGenerator():
    
    @staticmethod
    def convert_existing_cert( 
            cert_bytes: bytes, 
            key_bytes: bytes,
            friendly_name: str = CryptoStrings.DEFAULT_FRIENDLY_NAME.value,
            cert_password: str = CryptoStrings.DEFAULT_PFX_PASSWORD.value) -> bytes:
        cert = x509.load_pem_x509_certificate(cert_bytes)
        key = load_pem_private_key(key_bytes, None)
        encoded_cert = pkcs12.serialize_key_and_certificates(
            friendly_name.encode(),
            key, 
            cert, 
            None, 
            BestAvailableEncryption(cert_password.encode()))
        return encoded_cert

Passing in the byte-encoded cert.pem and privkey.pem contents to the function returns a password encoded .pfx byte string that can be written to a file and sent to the requestor.


RDP Certificates require RSA Encryption

Once I had successfully imported and bound a certificate to the RDP Service, I immediately noticed failures in inbound RDP sessions, and the following error was present in the Event Viewer Administrative Events Log:

A fatal error occurred when attempting to access the TLS server credential private key. The error code returned from the cryptographic module is 0x8009030D. The internal error state is 10001.
 The SSPI client process is svchost[TermService] (PID: 1424).

A comparison of certificates generated by Let's Encrypt and self-signed certificates revealed that ecdsa certificates were the problem here. If you're going to use Let's Encrypt certificates for RDP, ensure the --key-type is specified as rsa.


Prefer WMIC to Powershell

After many failed attempts of using the Set-WMIInstance function to specify the certificate to use, I defaulted to just calling wmic instead:

  wmic /namespace:\\root\cimv2\TerminalServices PATH Win32_TSGeneralSetting Set SSLCertificateSHA1Hash="$($Thumbprint)" 

Full Update Script

Below is the PowerShell script that runs monthly, used to rotate the certificate.

  
function Import-NewCertificate() {
    $cert_store = 'Cert:\LocalMachine\My\'
    $temp_name = Get-Random -Minimum 0 -Maximum 100000
    $temp_pfx = "$($env:TEMP)/$($temp_name).pfx"
    Invoke-WebRequest -Uri "https://$($endpoint)/apiv1/internal/cert/download_certificate?domain=$($domain)&certificate_type=PFX&password=$($temp_name)" -OutFile $temp_pfx
    Set-Location $env:TEMP
    $params = @{
        Exportable = $True
        FilePath = $temp_pfx
        CertStoreLocation = $cert_store
        Password=$temp_name | ConvertTo-SecureString -AsPlainText -Force

    }
    $import_task = Import-PfxCertificate @params
    (Get-ChildItem -Path $cert_store | where {$_.Thumbprint -eq $import_task.Thumbprint}).FriendlyName = "win10-$((Get-Date).ToShortDateString())"
    return [pscustomobject]@{
        Thumbprint = $import_task.Thumbprint
        temp_pfx = $temp_pfx
    }
}

Function Set-NewestCertificate { 
    param([string]$Thumbprint)
    wmic /namespace:\\root\cimv2\TerminalServices PATH Win32_TSGeneralSetting Set SSLCertificateSHA1Hash="$($Thumbprint)"
}

Function Remove-OldCertificates{ 
    param([string]$Thumbprint)
    $self_name = $env:COMPUTERNAME.tolower()
    $cert_path = 'Cert:\LocalMachine\My\'
    $certs = Get-ChildItem -Path $cert_path  | where {$_.Thumbprint -ne $Thumbprint -and $_.Issuer -ne "CN=$($self_name)"}
    foreach ($cert in $certs) { 
        $cert_thumbprint = $cert.Thumbprint
        Write-Warning "Removing $($cert_thumbprint)"
        Remove-Item -Path ($cert_path + $cert_thumbprint)
    }
}

Function Remove-TempFiles { 
    param([string[]]$files) 
    foreach ($file in $files) {
        Remove-Item -Path $file -Force
    }
}
 
$update_info = Import-NewCertificate
Write-Output $update_info.Thumbprint
Set-NewestCertificate -Thumbprint $update_info.Thumbprint
Remove-OldCertificates -Thumbprint $update_info.Thumbprint
Remove-TempFiles -files $update_info.temp_pfx