Synchroniser un certificat SSL avec reverse proxy et serveur IIS

Dans la continuation de mes articles sur Let’s Encrypt, voici un petit article permettant de résoudre un problème qui se pose lors de l’utilisation d’un reverse proxy.

Soit la situation suivante :

  • un serveur applicatif HTTPS utilisant le magasin de certificat de Windows, accédé en direct par les postes de travail internes
  • un reverse proxy HTTPS permettant d’accéder à ce serveur applicatif depuis Internet

On se retrouve alors avec potentiellement 2 serveurs HTTPS distinct donc deux certificats distincts. On pourrait les aligner, mais les certificats Let’s Encrypt ayant une durée de vie de 3 mois, la surcharge induite pour effectuer ce travail manuellement commence à peser, à moins d’automatiser cette opération avec un simple script.

Initialisation

La première fois est manuelle, et consiste à charger le certificat avec sa clé privée, donc obligatoirement sous forme PFX, depuis le reverse proxy vers le serveur applicatif. Cela va servir à charger la clé privée au niveau du serveur applicatif, opération qui ne sera pas faite ultérieurement, et donc simplifie la maintenance d’un point de vue de la sécurité. En effet il suffira par la suite de récupérer uniquement le certificat, donc la partie publique, ce qui se fait simplement en faisant une requête HTTPS au reverse proxy.

Récursion Répétition

Comme dit au premier paragraphe, la clé privée ne change pas, seul le certificat est renouvelé régulièrement auprès de Let’s Encrypt. Le script va donc :

  1. Effectuer une requête HTTPS au reverse proxy, en spécifiant l’hôte permettant de sélectionner le bon site (via l’extension SNI)
  2. La réponse, quelque soit le résultat HTTP (200, 404, 401…) contient le certificat, qu’on importe dans le magasin de certificat
  3. Le magasin de certificat contient la clé privée, ajouté précédemment, et un certificat sans clé privée mais dont la clé publique correspond à la clé privée. On va exécuter un programme qui va permettre de réassocier ces deux éléments
  4. La dernière étape consiste à reconfigurer IIS pour utiliser le nouveau certificat.

C’est l’objectif du script ci-dessous qui pourra être exécuté de manière périodique via le planificateur de tâches.

# url permettant d'atteindre le reverse proxy
# depuis le serveur applicatif
$address = 'https://rp-out.kveer.fr'
# nom d'hôte à envoyer au reverse proxy pour sélectionner le bon site
$headerHost = 'tfs.dev.kveer.fr'
# nom du site IIS devant utiliser le certificat
$site = 'Team Foundation Server'

$CertStore = 'cert:\LocalMachine\WebHosting'
Import-Module WebAdministration
$tmp = "$env:TEMP\_cert_import_" + (Get-Random) + ".crt"

$wr = [Net.WebRequest]::CreateHttp($address)
$wr.Host=$headerHost
$wr.AllowAutoRedirect=$false
try { $wr.GetResponse() } catch {}
$cert = $wr.ServicePoint.Certificate
$bytes = $cert.Export([Security.Cryptography.X509Certificates.X509ContentType]::Cert)
Set-Content -Value $bytes -Encoding Byte -Path $tmp
$c = Import-Certificate -CertStoreLocation $CertStore -FilePath $tmp
certutil -repairstore WebHosting $c.SerialNumber
Remove-Item $tmp

$currentSSLBinding = Get-Item IIS:\SslBindings\* | Where-Object { $_.Port -eq 443 -and $_.Sites -eq $site }
$currentSSLBindingName = $currentSSLBinding.PSChildName

$currentSSLBinding | Remove-Item
Get-Item -Path "$CertStore\$($c.Thumbprint)" | New-Item -Path IIS:\SslBindings\$currentSSLBindingName

Enjoy !

Let’s Encrypt for Windows

Comme certain l’on peut-être vu, Mozilla puis Google ont décidé de révoquer les certificats racine de StartSSL sur leur version du navigateur sorti en janvier 2017, suite à des comportements particulièrement douteux de la part de WoSign, tant au niveau technique qu’au niveau de la transparence et de la communication. WoSign contrôlant totalement StartSSL suite à un rachat.

C’est une décision que je félicite car aujourd’hui, toute la sécurité Web repose sur l’utilisation de certificats, lesquels sont émis par une ou plusieurs autorité(s) intermédiaire(s) puis une autorité racine. La différence technique entre une autorité intermédiaire et une autorité racine est simplement au niveau des certificats présent sur les magasins de certificats côté client. En effet un des critères pour qu’un certificat soit reconnu comme valide, est qu’il soit signé par un certificat d’autorité reconnu. Afin d’être reconnu, ce certificat doit soit être lui-même signé par un certificat d’autorité reconnu, soit être manuellement accepté. On voit ainsi se former une chaîne de confiance, où il suffit de reconnaître un certificat d’autorité (qu’on appellera alors certificat d’autorité racine) pour reconnaître automatiquement tout certificat émis par cette autorité, de manière récursive. Cela présente notamment l’avantage de limiter le nombre de certificats à ajouter à la liste des certificats d’autorité de confiance côté client, parce qu’il y en a vraiment beaucoup.

Chaîne de confiance

Un problème majeur est qu’avec le système actuel, toute autorité de certification est en capacité d’émettre un certificat pour n’importe quel domaine. Et ce n’est pas l’ajout d’un en-tête HTTP Public Key Pinning qui peut résoudre ce problème, en effet, à partir du moment où un MITM est possible en SSL, modifier l’en-tête HPKP est un jeu d’enfant. C’est donc une bonne chose que ces grands groupes restent vigilant quant aux exactions de ces autorités, même si très sincèrement ça m’a aussi fait chier.

D’un autre côté, la décision a été prise fin octobre 2016 pour une prise d’effet concrète dès mi-janvier 2017 au niveau du système de messagerie, puis quelques jours après avec la sortie de Chrome 56. On serait tenté de croire que 2,5 mois est plutôt long, mais ça n’a pas été le cas. Bien que prévenu en avance, il n’a pas été possible de dégager suffisamment de temps pour effectuer le renouvellements de tous nos certificats émis par StartSSL, la prise d’effet nous a pris de cours et il a fallu faire les remplacements à l’arrache.

Let’s Encrypt, shall we go ?

En remplacement de StartSSL, nous avons 3 possibilités :

  • la classique autorité, qui te fait payer rubis sur ongle chaque certificat émis
  • let’s encrypt, qui est une initiative open source, soutenue par Mozilla et permet d’émettre gratuitement des certificats RSA ou EC avec l’autorisation du owner du site web pour une durée de 3 mois
  • certcom, qui est similaire à StartSSL, à savoir l’émission de certificats gratuits, mais cette autorité n’est reconnue de base par aucun navigateurs ou OS majeur

Let’s Encrypt semble une bonne solution mais deux choses m’avais rebuté jusqu’à présent :

  • La difficulté de trouver et comprendre la spécification de leur web services pour les étapes de vérification et de génération du certificat. Je n’ai à ce jour toujours rien trouvé si ce n’est du charabia abscons sans rapport avec ce workflow
  • Le programme client officiel qui exige d’être root pour effectuer le workflow. Le programme est monstrueusement gros d’une part, donc nécessite un audit plutôt long, et la nécessité d’être root est clairement un abus de pouvoir ou une fainéantise des devs, au choix. Dans les deux cas, c’est mal.

Je suis tombé mi-décembre sur acme-tiny, qui est un client open-source pour let’s encrypt en python et particulièrement light. Suffisamment pour auditer et comprendre son fonctionnement, et générer un script non-root qui saura faire le job dans les règles de l’art. C’est ce qui est en place sur mes environnements Linux, et cela inclut notamment ce blog. Un tutoriel existe pour sa mise en place sous Gentoo.

Partant de ce succès sous Linux, je me suis mis en tête de porter cette solution sous Windows+IIS. Le seul travail de recherche restant étant d’assurer une compatibilité de acme-tiny pour Windows. Ce dernier étant écrit en python, le travail est déjà mâché et s’est au final avéré relativement rapide.

Pré-requis installation

  1. Installer un interpréteur Python 2.7, par exemple ActivePython
  2. A partir de Python 2.7.10, l’interprêteur valide les certificats par défaut. Cela requiert d’installer certifi de la manière suivante : pip install certify[secure]
  3. Installer les binaires OpenSSL pour Windows. Il n’existe pas de version compilée de OpenSSL officielle. J’utilise ceux fournit par Shining Light Productions, la version Light est suffisante.
  4. Créer le dossier %SYSTEMDRIVE%\letsencrypt  et déposez-y le script Python ainsi que les deux scripts PowerShell (voir au bas du post pour les récupérer).
    • Ce dossier contiendra les clés privées des sites web et ne doit donc être accessible en lecteur que par l’utilisateur qui va effectuer le renouvellement de certificat. Pour ma part, il s’agit de SYSTEM.
  5. Créer le dossier et les sous-dossiers %SYSTEMDRIVE%\inetpub\letsencrypt\acme-challenge
    • Ce dossier doit être accessible en lecture écriture par le processus qui va effectuer le renouvellement de certificat
    • Ce dossier doit être accessible en lecture par les sites web
  6. Ajouter ce fichier web.config au dossier %SYSTEMDRIVE%\inetpub\letsencrypt\acme-challenge
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
	<system.webServer>
		<staticContent>
			<mimeMap fileExtension=".*" mimeType="text/plain"/>
		</staticContent>
		<handlers>
			<clear />
			<add name="StaticFile" path="*" verb="GET" type="" modules="StaticFileModule,DefaultDocumentModule,DirectoryListingModule" scriptProcessor="" resourceType="Either" requireAccess="Read" allowPathInfo="false" preCondition="" responseBufferLimit="4194304" />
		</handlers>
	</system.webServer>
</configuration>

Avant de générer le premier certificat, il est nécessaire de créer une clé privée afin d’être reconnu par Let’s Encrypt. Cela se fait avec les commandes suivantes dans une console administrative :

cd "$env:SystemDrive\letsencrypt"
Import-Module .\get-signed-cert.ps1

# where letsencrypt-account.key is where the key will be written
New-LetsEncryptAccountKey -Outfile letsencrypt-account.key

Note : pour l’instant le script d’acme-tiny a été modifié pour le faire fonctionner sous Windows et ne fonctionne plus pour Linux. Je dois le nettoyer un peu et ferais un pull-request sur le dépôt afin d’intégrer cette capacité au script sans besoin de le modifier manuellement.

Pré-requis pour chaque site web

Une fois l’installation au niveau du serveur effectué, il y a une manipulation à effectuer pour chaque site web à sécuriser. Il faut ajouter un répertoire virtuelle à la racine du site, nommé .well-known  et devant pointer sur %SYSTEMDRIVE%\inetpub\letsencrypt , créé précédemment, comme suit :

Puis nous allons générer une clé privée (au moins deux si vous utilisez HPKP) dédiée à ce site. A ce jour, il existe deux types de clefs qui sont supportés, les vénérables clés RSA et les fancy clés à base de courbes elliptiques.

cd "$env:SystemDrive\letsencrypt"
Import-Module .\get-signed-cert.ps1

# for generating an ecc key
New-ECPrivateKey -Password aFuckingGoodPassword -Outfile mywebsite-ec.key

# for generating an rsa key
New-RSAPrivateKey -Password anotherFuckingGoodPassword -Outfile mywebsite-rsa.key

Le script génère des clés RSA de 2048 bits, et des clés ECC en utilisant, pour le moment, la courbe prime256v1, aussi nommé NIST-P256.

Warning : Je sais que cette courbe présente des faiblesses, mais je n’ai pas encore eu le temps de me pencher sur leurs impacts ni pu trouver un remplaçant, sachant que toutes les courbes ne sont pas aptent à faire du HTTPS en TLS 1.2.

Générer un Certificat

La génération d’un certificat se fait en une seule commande qui devrait bien se passer, pourvu que vous ayez bien suivi toutes les étapes d’installation, y compris au niveau de la configuration des accès.

Import-Module .\get-signed-cert.ps1

Renew-Certificate -SiteName "MyWebSite1" -KeyFile MyWebSite1-ec.key -KeyFilePassword 'a Fucking good Password' -LetsEncryptKey letsencrypt-account.key

Il reste l’automatisation. Pour ce faire, nous allons simplement enregistrer le script ci-dessus dans le fichier renew MyWebSite1.ps1  et l’enregistrer dans le dossier %SYSTEMDRIVE%\letsencrypt . Puis il reste à créer la tâche planifiée. Afin de ne pas avoir à gérer le changement de mot de passe sur les utilisateurs standard, j’ai configuré la tâche pour qu’elle s’exécute avec l’utilisateur SYSTEM.

Création d'une tâche planifiée exécutée par SYSTEM

L’action sera l’exécution d’un programme, avec les paramètres suivants :

  • le programme : %windir%\System32\WindowsPowerShell\v1.0\powershell.exe
  • les arguments: -Run .\renew.ps1
  • Le dossier de travail (où se trouve le script renew.ps1) : C:\letsencrypt
    La tâche planifiée va exécuter le programme %windir%\system32\WindowsPowerShell\v1.0\powershell.exe -Command ". renew mywebsite.ps1"Configuration du trigger pour un déclenchement le 1 de chaque mois

Le certificat étant valide 3 mois, on peut se limiter à un renouvellement mensuel, ce qui permet en outre d’avoir 2 renouvellements ratés sans que cela ne perturbe l’accessibilité du site.

Le CSR et le certificat ne sont pas conservé sur le disque, seul la clé est conservée. Le CSR est bien suffisant pour récupérer le certificat signé, cependant l’import d’un certificat avec sa clé privée dans le magasin de certificats de Windows nécessite de l’importer avec sa clé privée. Il n’est à ma connaissance pas possible par exemple d’importer juste le certificat (publique), puis de l’associer avec la clé qui va bien dans le magasin de certificat.

Le Script

On y est, voici le cœur de la machine.

OpenSSL ne se trouvant par défaut pas dans le path, il est nécessaire d’indiquer au script où il se trouve, ce qui est fait à la ligne 4, et que vous aurez peut-être à modifier.

# script to generate a signed certificate for a single domain
Import-Module WebAdministration -ErrorAction Stop

$OPENSSL_PATH = 'C:\OpenSSL-Win64\bin'
$DefaultCertStore = Cert:\LocalMachine\WebHosting

if (-not ($env:Path -contains $OPENSSL_PATH)) {
    $env:Path += ";$OPENSSL_PATH"
}

function Get-TempPassword {
    param(
        [int]$Size
    )

    -join ((45..57) + (65..90) + (97..122) | Get-Random -Count $Size | % {[char]$_})
}

function New-RSAPrivateKey {
    param(
        [string]$Password,
        [string]$OutFile,
        [int]$KeySize = 2048
    )

    if ([string]::IsNullOrEmpty($Password)) {
        openssl genrsa -out $OutFile $KeySize 2> $null
    } else {
        if ($Password.Length -lt 4) {
            Write-Error "The password MUST be greater than 4 chars"
            return
        }
        
        openssl genrsa -out $OutFile -aes128 -passout pass:$Password $keySize 2> $null
    }
}

function New-LetsEncryptAccountKey {
    param(
        [string]$Outfile
    )

    New-RSAPrivateKey -OutFile $Outfile -KeySize 4096
}

function New-ECPrivateKey {
    param(
        [string]$Password,
        [string]$Outfile
    )

    $ec_name = 'prime256v1'

    $ec_param = & openssl ecparam -conv_form compressed -name $ec_name -genkey

    if ([string]::IsNullOrEmpty($Password)) {
        $ec_param | & openssl ec -out $Outfile 2> $null
    } else {
        if ($Password.Length -lt 4) {
            Write-Error "The password MUST be greater than 4 chars"
            return
        }

        $ec_param | & openssl ec -out $Outfile -aes128 -passout pass:$Password 2> $null
    }
}

function New-CertificateRequest {
    param(
        [string]$KeyFile,
        [string]$KeyFilePassword,
        [string]$DomainName,
        [string]$OutFile
    )

    if ([string]::IsNullOrEmpty($KeyFilePassword)){
        openssl req -new -subj /CN=$DomainName/ -sha256 -key $KeyFile -out $OutFile
    } else {
        openssl req -new -subj /CN=$DomainName/ -sha256 -key $KeyFile -out $OutFile -passin pass:$KeyFilePassword
    }    
}

function Renew-Certificate {
    param(
        [string]$SiteName,
        [string]$LetsEncryptKey,
        [string]$KeyFile,
        [String]$KeyFilePassword,
        [string]$CertStore = $DefaultCertStore
    )

    $rootLetsEncrypt = "$env:SystemDrive\inetpub\lets-encrypt"
    $webSite = Get-Website -Name $SiteName
    $rootPath = $webSite.physicalPath
    $bindingString = ($webSite | Get-WebBinding -Protocol http).bindingInformation
    $domainName = $bindingString.Split(':')[2]

    if (-not (Test-Path $rootLetsEncrypt -PathType Container)) {
        New-Item $rootLetsEncrypt -ItemType Directory
    }

    $csr = [System.IO.Path]::GetRandomFileName()
    $csrFullPath = [System.IO.Path]::Combine($env:TEMP, $csr)
    $crt = [System.IO.Path]::GetRandomFileName()
    $crtFullPath = [System.IO.Path]::Combine($env:TEMP, $crt)

    Write-Host "CRT: $crtFullPath"

    New-CertificateRequest -KeyFile $KeyFile -KeyFilePassword $KeyFilePassword -DomainName $domainName -OutFile $csrFullPath
    & python .\acme-tiny.py --account-key $LetsEncryptKey --csr $csrFullPath --acme-dir $rootLetsEncrypt\acme-challenge > $crtFullPath

    $pfx = [System.IO.Path]::GetRandomFileName()
    $pfxFullPath = [System.IO.Path]::Combine($env:TEMP, $pfx)
    $pfxPassword = Get-TempPassword 12
    $displayName = "Let's Encrypt $domainName $([datetime]::Today.ToString("yyyy-MM-dd"))"
    Get-Content -Path $KeyFile,$crtFullPath | & openssl pkcs12 -export -name $displayName -passout pass:$pfxPassword -passin pass:$KeyFilePassword -out $pfxFullPath
    $cert = Import-PfxCertificate -FilePath $pfxFullPath -Password (ConvertTo-SecureString -AsPlainText -Force $pfxPassword) -CertStoreLocation $CertStore -ErrorAction Stop

    $currentSSLBinding = Get-Item IIS:\SslBindings\* | Where-Object { $_.Port -eq 443 -and $_.Host -eq $domainName }
    $currentSSLBindingName = $currentSSLBinding.PSChildName

    $currentSSLBinding | Remove-Item
    Get-Item -Path "$CertStore\$($cert.Thumbprint)" | New-Item -Path IIS:\SslBindings\$currentSSLBindingName

    Remove-Item $pfxFullPath
    Remove-Item $crtFullPath
    Remove-Item $csrFullPath
}

Ce script se découpe en 2 parties :

  • La première partie est principalement du wrapping autour de openssl pour générer des clés privées en RSA ou ECDSA.
  • La seconde partie est l’utilisation de acme-tiny (wrapping), et la reconfiguration de IIS et le magasin de certificat de Windows avec des cmdlets natives

Il est relativement simple à lire. On peut remarquer qu’un binding ne peut pas être modifié, il doit être détruit puis être recréée.

Références