Kostenlose SSL-Zertifikate von Let's Encrypt für Terminal-Server automatisiert ausrollen

Hallo zusammen,

nachdem ich schon eine Weile nicht mehr mein Blog genutzt habe, möchte ich heute die Gelegenheit nutzen, das Ergebnis einer kleinen PowerShell-Übung zu posten. Die Herausforderung, vor der ich stand, hat was mit meinem Server zu Hause zu tun. Auf diesem läuft nicht nur meine Dateiablage über Storage Spaces, sondern auch eine virtuelle Maschine. Beide Computer administriere ich primär per RDP. Was mich schon immer dabei gestört hat, ist die Sicherheitsabfrage wegen des selbstsignierten Zertifikats des RDP-Listeners.

Jetzt kann man natürlich hingehen und einfach Network Level Authentication für RDP abschalten. Im meinem privaten Heimnetz wäre das sogar vertretbar. Als alter Security-Freak hatte ich mich lieber für die private Nutzung kostenloser Zertifikate von StartCom entschieden, die jeweils ein Jahr Laufzeit hatten. Nun stand ich jedesmal nach Ablauf dieses einen Jahres vor der Aufgabe, das Zertifikat zu erneuern, was im Prinzip das Ausstellen eines neuen und das Wegwerfen des alten Zertifikats bedeutete.

Bei zwei RDP-Listenern war ich nun nach drei Jahren des manuellen Aufwandes langsam überdrüssig. Der Artikel Distrusting New WoSign and StartCom Certificates war schlußendlich der Sargnagel meiner bisherigen Vorgehensweise und ich schaute mich mal um, was für Alternativen heute existieren. Let's Encrypt sah am vielversprechendsten aus und wenn selbst Fefe behauptet, certificatia non olet, dann kann man das ja mal ausprobieren.

Folgende Probleme taten sich dabei auf:

  1. Terminal Server laufen auf Windows und diese ist bisher kein First Class Citizen bei Let's Encrypt.
  2. Die Integration eines validen SSL-Zertifikats in den RDP-Listener über den Fingerabdruck des Zertifikats muss ich jedes mal neu nachlesen.
  3. Die Laufzeit der Zertifikate beträgt nur 90 Tage.

Automatisierung ist also Pflicht. Da ergibt sich die erste Frage von ganz allein: Hat das Problem schon mal jemand im Internet gelöst? Eine kurze Recherche förderte etwas kompliziertes bei Exchange 2016: Kostenlose Zertifikate von Let’s Encrypt zu Tage. Danach las ich Using Let’s Encrypt to secure Windows Remote Desktop connections und fand mein Problem recht schön beschrieben, allerdings ohne eine automatisierte Lösung. Schließlich stieß ich auf Automating certificate renewal with Let’s Encrypt and ACMESharp on Windows, was schon mal recht brauchbar aussah, um mit wenigen Änderungen auch für Terminal Server zu funktionieren.

PowerShell war für mich eh von Anfang an gesetzt - ich fang nicht an, mit grep, sed, awk, tr & Co. auf Textschnipseln rumzuscripten. Mit ACMESharp gibt es eine PowerShell-Erweiterung für Let's Encrypt. Man folgt also erst einmal der Quick Start-Anleitung und installiert das Modul über die PowerShell Gallery. Unter Windows 10 stößt man dabei auf einen kleinen Fehler Get-Certificate cmdlet already exists during install, der mit dem zusätzlichen Parameter -AllowClobber gelöst wird:

 
Save-Module -Name ACMESharp -Path $env:TEMP 
Install-Module -Name ACMESharp -AllowClobber

Nach der Installation des Moduls lädt man es und richtet einmalig einen Account ein:

 
Import-Module ACMESharp
Initialize-ACMEVault
New-ACMERegistration -Contacts mailto:somebody@example.tld -AcceptTos

Nun noch den zu verwendenden Hostnamen registrieren:

 
$domain = "myserver.example.tld"
$alias = "myserver"
$certname = "$domain-$(get-date -format yyyy-MM-dd--HH-mm)"
$pfxfile = "$env:USERPROFILE\Downloads\Certs\$certname.pfx"

Ich habe mich für die DNS-Challange entschieden:

 
New-ACMEIdentifier -Dns $domain -Alias $alias
Complete-ACMEChallenge $alias -ChallengeType dns-01 -Handler manual

Nun einen TXT-Eintrag mit den von Let's Encrypt zurückgegebenen Wert in meiner Domäne beim DNS-Provider hinterlegen, testen und bei Erfolg die Authorisierung bestätigen:

 
Submit-ACMEChallenge $alias -ChallengeType dns-01
Update-ACMEIdentifier -Ref $alias

Wenn nach dem Update-ACMEIdentifier ein valid zurückgemeldet wird, hat der Vorgang erfolgreich funktioniert. Für diesen Servernamen können wir jetzt in den nächsten 90 Tagen automatisiert Zertifikate ausrollen. Wie, schon wieder nur 90 Tage? Tja, das ist leider richtig. Das zu Automatisieren will ich mir in den nächsten Wochen anschauen. Azure DNS schwebt mir da derzeit vor, entweder als authorativer DNS-Server oder als Hidden Primary für meine existierende Domäne. Bei Azure DNS kann man problemlos DNS-Einträge über Azure PowerShell administrieren und von bestehenden DNS-Servern die Zonendatei mit Azure CLI direkt importieren.

Als nächsten Schritt erzeugen wir ein sicheres Passwort auf dem Zielserver, um das Kennwort des Zertifikats nicht im Klartext im Script rumliegen zu lassen. Dazu müssen folgende Kommandos zwingend auf dem Zielsystem in dem Benutzerkontext ausgeführt werden, in dem später das Script laufen wird (siehe dazu zum Beispiel Using saved credentials securely in PowerShell scripts und Decrypt PowerShell Secure String Password). Vorher natürlich bitte den Ordner 'Certs' im Download-Ordner des Benutzerprofils anlegen, in dem man das Script später laufen lassen will.

 
$password = "H0chs!cheres Passwort 0815"
$secureStringPwd = $password | ConvertTo-SecureString -AsPlainText -Force
$secureStringText = $secureStringPwd | ConvertFrom-SecureString
Set-Content "$env:USERPROFILE\Downloads\Certs\ExportedPassword.txt" $secureStringText

Zum Testen des Scriptes empfehle ich dringend die Nutzung des Staging-Systems von Let's Encrypt und die spätere Umsetzung in die Live-Umgebung. Das läßt sich über eine Umgebungsvariable steuern. Ich habe :sys auf die Liveumgebung und :user auf die Staging-Umgebung zeigen lassen. So kann man mit :user testen, ob alles läuft und dann auf :sys umschalten, wenn man live gehen will. Natürlich muss man sowohl im Staging-, als auch im Live-System den gewünschten Hostnamen vorher registrieren (siehe oben ab New-ACMEIdentifier -Dns $domain -Alias $alias):

 
$env:ACMESHARP_VAULT_PROFILE=":user"

Kommen wir jetzt zum eigentlichen Script. Wir sollten bis hier die einmalige Registrierung bei Let's Encrypt nach der Quick Start-Anleitung manuell abgeschlossen haben und ein sicheres Kennwort in der Datei %USERPROFILE%\Downloads\Certs\ExportedPassword.txt hinterlegt haben. Alle nachfolgenden Zeilen sind dann das Script, welches man regelmäßig aller 60 Tage auf dem Zielserver per Aufgabenplanung automatisiert laufen läßt:

 
Import-Module ACMESharp

#
# Script parameters
#

$domain = "myserver.example.tld"
$alias = "myserver"
$certname = "$domain-$(get-date -format yyyy-MM-dd--HH-mm)"

#
# Environmental variables
#

$env:ACMESHARP_VAULT_PROFILE=":sys"
$pfxfile = "$env:USERPROFILE\Downloads\Certs\$certname.pfx"
$secureStringText = Get-Content "$env:USERPROFILE\Downloads\Certs\ExportedPassword.txt" | ConvertTo-SecureString
$CertificatePassword = (New-Object PSCredential "user",$secureStringText).GetNetworkCredential().Password

#
# Script setup - should be no need to change things below this point
#

$ErrorActionPreference = "Stop"

Try {
    Write-Output "Attempting to renew Let's Encrypt certificate for $domain"

    # Generate a certificate
    Write-Output "Generating certificate for $alias"
    New-ACMECertificate $alias -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 -ne "") {
            Start-Sleep 6
            $i++
        }
    } until($certinfo.SerialNumber -ne "" -or $i -gt 10)

    if($i -gt 10) {
        Write-Output "We did not receive a completed certificate after 60 seconds"
        Exit
    }

    # Export Certificate to PFX file
    Get-ACMECertificate $certname -ExportPkcs12 $pfxfile -CertificatePassword $CertificatePassword

    # Import the certificate to the local machine certificate store 
    Write-Output "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 RDP listener
    Write-Output "Bind certificate with Thumbprint $certThumbprint"
    $wmipath = (Get-WmiObject -class "Win32_TSGeneralSetting" -Namespace root\cimv2\terminalservices -Filter "TerminalName='RDP-tcp'").__path
    Set-WmiInstance -Path $wmipath -argument @{SSLCertificateSHA1Hash=$certThumbprint}

    # Remove expired LetsEncrypt certificates for this domain
    Write-Output "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-Output "Removing certificate $($cert.Thumbprint)"
            $store.Remove($cert)
        }
    }
    $store.Close() 

    # Finished
    Write-Output "Finished"
    
} Catch {
    Write-Output $_.Exception
    $ErrorMessage = $_.Exception | format-list -force | out-string
    Write-Output "Let's Encrypt certificate renewal for $domain failed with exception`n$ErrorMessage`r`n`r`n"
    Exit
}

Zwei letzte Tipps noch am Rande:

  1. Setzt die Time to Live für den TXT-Eintrag in Eurem DNS-Server auf einen niedrigen Wert wie zum Beispiel 300 Sekunden, damit ihr beim Umschalten vom Staging- auf das Live-System oder im Falle eines Fehlers nicht 8 Stunden oder länger warten müßt, bis Let's Encrypt mal wieder Euren TXT-Eintrag abfragt.
  2. Die Zertifikate liegen jetzt im Download-Ordner des Benutzers, in dessen Umgebung das Script läuft. Sichert den Ordner mit entsprechenden ACLs ab, so dass darauf kein Unbefugter Zugriff hat oder legt die woanders ab, wo es sicherer ist. Ich baue da auf Eure Kreativität.

Have fun!
Daniel