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

Construire sa propre autorité de certification

Ce billet explique comment on peut monter une autorité de certification à 2 sous et surtout l’utiliser au quotidien sans pour autant sacrifier les exigences techniques en terme de sécurité.

Avant la technique, remettons en contexte, une autorité de certification doit répondre à au moins ces deux conditions :

  • les clés privées des autorités racines et intermédiaires doivent être tenues secrètes
  • les clés privées des autorités racines et intermédiaires ne doivent pas être perdues, autrement dit, il faut en garder au moins une copie, également secrète ailleurs.

Le mot « secret » est bien entendu à comprendre au sens « chiffré » et non pas caché en loose dans un dossier invisible ou similaire.

Et la contrainte :

  • la solution doit coûter un minimum

D’où la solution suivante, qui rempli toutes les exigences :

  • le stockage se fait sur une Dropbox (ou hébergement cloud similaire)
  • toutes les clés sont chiffrées
  • l’outil utilisé pour la manipulation et génération des certificats sera OpenSSL, qui est open-source et multi-plateforme (vraiment multi-plateforme, c’est à dire Linux, Cygwin et Windows)

Et maintenant la technique !

L’autorité de certification

OpenSSL est assez mal fichu sur la manière de configurer car il va nous falloir un fichier de configuration pour les certificats d’autorité, et un séparé pour les autres type de certificats. Voyons le contenu pour les certificats d’autorité, puisque c’est ce qui nous intéresse maintenant :

oid_section = new_oids

[ new_oids ]
STREET = 2.5.4.9
S = 2.5.4.8
PostalCode = 2.5.4.17

[ distinguished_name ]
C = FR
O = Kveer
OU = Root CA
CN = Kveer Root CA

[ req ]
default_bits = 4096
default_md = sha1
distinguished_name = distinguished_name
reqs_extensions = x509_ca
prompt = no
utf8 = yes

[ x509_ca ]
basicConstraints = critical,CA:true
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer
keyUsage = critical, keyCertSign, cRLSign
crlDistributionPoints = URI:http://pki.kveer.com/my.crl

Rien d’extraordinaire ici. Le certificat à générer est marqué comme une autorité de certification, on précise son usage : signer des certificats et des listes de révocation de certification (CRL) et on précise le point de distribution des CRLs. On aurait pu également limiter la longueur de la chaîne de certification, je ne l’ai pas fait.
Et le script bash qui va bien :

#!/bin/bash

#cannot use ecdsa as windows nt 5.x does not support it yet.
# prime192v1 seems to be the best one otherwise.

CA_KEY=ca.key
CA_CERT=ca.crt
CONF=kveer-ca.openssl.cnf

if [[ -f ca.key && -f ca.crt ]]; then
echo 'There is already an existing CA root'
exit 1
fi

# password
stty -echo
read -p 'Please enter a password that will be used to encrypt all private keys: ' PASSWD;echo
stty echo

# key generation
openssl genrsa -aes192 -out ca.key -passout pass:"$PASSWD" 4096

# root ca cert generation
openssl req -new -x509 -extensions x509_ca -key ca.key -passin pass:"$PASSWD" -days 7300 -config $CONF -utf8 -out tmp.crt

mkdir certs reqs keys
touch index.txt
echo 01 > serial.txt
echo 00 > crlnumber

J’aurais souhaité utiliser l’algorithme ECDSA qui est pour le moment considéré plus sur, à taille de clé équivalente, mais pour conserver une compatibilité avec Windows NT5.1 (XP et 2003 Server), je suis resté à l’algorithme RSA.

  1. Le script commence par une petite vérification : ça serait dommage d’écraser son certificat racine, n’est-ce pas ?
  2. Il va demander un mot de passe (ce qui évitera de le taper 36 fois), lequel va servir à chiffrer la clé privée. Attention : c’est à ce moment qu’est définit LE mot de passe pour chiffrer toutes vos clés privées, il a donc intérêt à être costaud
  3. Il génère une clé privée, qu’il encode avant d’écrire sur le disque
  4. Il crée et signe un certificat dont la clé privée vient d’être généré
  5. Il crée quelques fichiers nécessaire à l’utilisation du module « ca » de openssl

Une fois le certificat créée, il ne reste plus qu’à déployer la partie publique (sous la forme du fichier ca.crt) vers tous vos postes pour que les futurs certificats émis puisse être validées par les postes clients.

Les certificats d’autorités intermédiaires

Avant de créer un certificat « standard », c’est à dire ceux qui servent à faire du SSL ou du S/MIME pour l’utilisateur final, je recommande vivement de créer encore un certificat d’autorité, signé par le précédent certificat, qu’on appelle alors certificat d’autorité intermédiaire.

La raison est que si par malheur le certificat racine vient à être compromis, vous êtes mort, tout le château de cartes tombe, vous devez repartir de zéro.
En revanche, si c’est un certificat d’autorité intermédiaire qui est compromis, vous êtes dans la merde également, mais il est possible de récupérer le coup en révoquant ce certificat au niveau du CRL de l’autorité racine, puis en recréant ce certificat d’autorité intermédiaire ainsi que de re-signer tous les certificats qu’il aurait pu émettre. Il n’est pas nécessaire de redéployer le certificat racine sur tous les postes, puisqu’il est safe.

Dans le schéma que je propose, la seule faille est la robustesse du mot de passe généré précédemment et d’éviter de le taper sur un poste dpoé au keylogger.
Si on stocke les fichiers sur une Dropbox, on peut considérer que les fichiers sont publics, et bien que cela puisse ne pas être agréable (on aurait la liste des certificats émis, leur type, leur usage…), l’autorité reste sûre tant que les clés privées ne sont pas déchiffrées, donc si le mot de passe utilisé ainsi que l’algorithme de chiffrage (ici : AES 192 bits) sont solides. Etant donné que AES 192bits est plus que solide, que la connaissance des certificats émis n’a que peu d’importance (avec toutes les conséquences que cela entraîne, puisque indirectement j’accepte de dévoiler une partie du réseau), je réduis la faiblesse de mon système au seul mot de passe.

Par ailleurs l’utilisation de certificats d’autorité intermédiaire permet de déléguer la gestion à un tiers ou un collègue à cet seule autorité intermédiaire sans risquer de tout compromettre. Pour rappel, il est possible de créer une autorité intermédiaire qui ne peut pas émettre de certificat d’autorité, en fixant la chaîne de certification à 0 sur cette autorité.

Les scripts maintenant. Tout d’abord le fichier de configuration :

oid_section = new_oids

[ new_oids ]
STREET = 2.5.4.9
S = 2.5.4.8
PostalCode = 2.5.4.17

[ req ]
default_bits = 4096
default_md = sha1
distinguished_name = distinguished_name

[ distinguished_name ]
countryName = Pays
countryName_default = FR
countryName_min = 2
countryName_max = 2

O = Sujet
O_default = Kveer

OU = Unité organisationnelle

CN = Common Name

[ x509_ca ]
basicConstraints = critical,CA:true
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer
keyUsage = critical, keyCertSign, cRLSign
crlDistributionPoints = URI:http://pki.kveer.com/my.crl

Puis le script :

#!/bin/bash

#cannot user ecdsa because windows nt 5.x does not support it yet.
# prime192v1 seems to be the best one otherwise.

CA_KEY=ca.key
CA_CERT=ca.crt
CONF=kveer-subca.openssl.cnf
CONF_CA=kveer.openssl.cnf

if [[ ! (-f ca.key && -f ca.crt) ]]; then
echo 'There is no CA root'
exit 1
fi

if [ $# < 1 ]; then
echo "$0 certificate_name"
exit 2
fi

# password
stty -echo
read -p 'Please enter a password that will be used to encrypt all private keys: ' PASSWD;echo
stty echo

# key generation
openssl genrsa -aes192 -out tmp.key -passout pass:"$PASSWD" 4096

# root ca cert generation
openssl req -new -extensions x509_ca -key tmp.key -passin pass:"$PASSWD" -config $CONF -utf8 -out tmp.csr
openssl ca -config $CONF_CA -extensions x509_ca -days 3650 -keyfile $CA_KEY -key $PASSWD -cert $CA_CERT -in tmp.csr -out "$1.crt"

if [[ ! -f "$1.crt" || ! -f tmp.key || ! -f tmp.csr ]]; then
echo 'Missing files, stopping here'
exit 3
fi

mv "$1.crt" "certs/$1.crt"
mv tmp.key "keys/$1.key"
mv tmp.csr "reqs/$1.csr"

mkdir -p "$1/certs" "$1/keys" "$1/reqs"
cp "certs/$1.crt" "$1/ca.crt"
cp "keys/$1.key" "$1/ca.key"
cp *.sh "$1/"
cp *.cnf "$1/"
touch "$1/index.txt"
echo '00' > "$1/serial.txt"

En essence, c’est similaire au script précédent, mais ici le fichier n’est pas auto-signé, et un dossier est créer pour préparer la gestion de l’autorité intermédiaire. On notera les quelques vérifications pour éviter tout accident. Ce n’est pas exhaustif, mais ça me convient pour le moment.
Arrivé à cet étape nous avons une autorité de certification racine, ainsi qu’au moins une autorité de certification intermédiaire. On peut s’attaquer au plus intéressant.

Les certificats « serveur » et « client »

Par certificat « serveur » j’entends ceux utilisé pour authentifier le serveur, donc ceux servant pour initier une connexion SSL sur un serveur web, un protocol de messagerie comme SMTP, POP, IMAP en SSL ou TLS, ou encore un serveur FTP.
Et par certificat « client » j’entends ceux utilisés par Outlook pour signer ses messages.
On va avoir un « gros » fichier de configuration pour ces deux types de certificats :

oid_section = new_oids

[ new_oids ]
STREET = 2.5.4.9
S = 2.5.4.8
PostalCode = 2.5.4.17

[ ca ]
default_ca = default_ca

[ default_ca ]
dir = .
new_certs_dir = $dir/certs
certificate = $dir/ca.pem
private_key = $dir/private/ca.key
RANDFILE = $dir/private/.rand
default_days = 365
default_crl_days = 30
default_md = sha1
database = $dir/index.txt # list of all signed certificates
unique_subject = no # allows to generate multiple certs with same subject name
serial = $dir/serial.txt # next cetificate serial
crlnumber = $dir/crlnumber # next crl number
email_in_dn = no # no email in distinguished name
name_opt = ca_default
cert_opt = ca_default
copy_extensions = copy # copy all new extensions from request to certificate
policy = policy

[ policy ]
C = match
O = match
OU = match
CN = supplied

[ x509_server ]
basicConstraints = CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
crlDistributionPoints = URI:http://pki.kveer.com/my_kveer_vpn_ca.crl

[ x509_client ]
basicConstraints = CA:false
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid, issuer
keyUsage = digitalSignature, keyAgreement
extendedKeyUsage = clientAuth
crlDistributionPoints = URI:http://pki.kveer.com/my_kveer_vpn_ca.crl

[ req ]
default_bits = 4096
default_md = sha1
distinguished_name = distinguished_name

[ distinguished_name ]
countryName = Pays
countryName_default = FR
countryName_min = 2
countryName_max = 2

O = Sujet
O_default = Kveer

OU = Unité organisationnelle
OU_default = VPN Services

CN = Common Name

Là j’ai pris l’exemple de mon autorité intermédiaire gérant les certificats pour un VPN. On prendra soin de changer le point de distribution des CRL : chaque autorité de certification doit avoir un point de distribution distinct, sinon il ne sert à rien et risque d’invalider les certificats d’une autre autorité.
Le script bash :

#!/bin/bash

#cannot user ecdsa because windows nt 5.x does not support it yet.
# prime192v1 seems to be the best one otherwise.

CA_KEY=ca.key
CA_CERT=ca.crt
CONF=kveer.openssl.cnf
CONF_TMP=_openssl.conf

if [[ ! (-f ca.key && -f ca.crt) ]]; then
    echo 'There is no CA root'
    exit 1
fi

if [ $# -lt 1 ]; then
    echo "$0 -n [certificate_name] -p [pathlen] -c [crlurl] [-e] [-s altsubject, -s...]"
    echo '-e for using an ecdsa key, else an rsa key'
    exit 2
fi

ECDSA=0
REUSE_PKEY=0
REUSE_REQ=0
REGEN=0
ONLY_REQUEST=0
SHA1=0
TEMPLATE=
FILENAME=
declare -a ALTSUBJECT
while getopts 'cen:rs:t:f:1' OPT
do
    case $OPT in
        c)
            ONLY_REQUEST=1
            ;;
        e)
            ECDSA=1
            ;;
        n)
            NAME=$OPTARG
            ;;
        r)
            REGEN=1
            REUSE_PKEY=1
            ;;
        s)
            ALTSUBJECT+=($OPTARG)
            ;;
        1)
            SHA1=1
            ;;
        t)
            TEMPLATE=$OPTARG
            ;;
        f)
            FILENAME=$OPTARG
            ;;
    esac
done

if [ "$FILENAME" = "" ]; then
    FILENAME=$NAME
fi

if [ -z "$NAME" ]; then
    echo 'Use -n to specify the certificate name.'
    exit 6
fi

if [ "$REGEN" -eq 0 -a -f "certs/$FILENAME.crt" ]; then
    echo 'A certificate with the same name exists already.'
    exit 5
fi

if [ -f "keys/$FILENAME.key" ]; then
    read -p 'An existing private has been found. Use it ? [yes|no] ' TEST;echo
    [[ "$TEST" = 'yes' || "$TEST" = 'y' ]] && REUSE_PKEY=1
fi

if [ -f "reqs/$FILENAME.csr" ]; then
    read -p 'An existing certificat request has been found. Use it ? [yes|no] ' TEST;echo
    [[ "$TEST" = 'yes' || "$TEST" = 'y' ]] && REUSE_REQ=1
fi

if [ "$TEMPLATE" = "" ]; then
    TEMPLATE=x509_server
fi

# password
stty -echo
read -p 'Please enter the master password: ' PASSWD;echo
stty echo

# key generation
if [ $REUSE_PKEY -eq 0 ]; then
    if [ $ECDSA -eq 1 ]; then
        openssl ecparam -conv_form compressed -name prime256v1 -genkey | openssl ec -aes128 -out tmp.key -passout pass:$PASSWD
    else
        openssl genrsa -aes192 -out tmp.key -passout pass:"$PASSWD" 2048
    fi
else
    ln -f "keys/$FILENAME.key" tmp.key
fi

if [ $REUSE_REQ -eq 0 ]; then
    # generate conf
    sed -e "s/^CN_default.*/CN_default = $NAME/g" \
        $CONF > $CONF_TMP
    if [ $SHA1 = 1 ]; then
        sed -i -e 's/sha256/sha1/g' $CONF_TMP
    fi
    if [ ${#ALTSUBJECT[@]} -gt 0 ]; then
        dns=0
        ip=0
        
        sed -i -e "/\\[[ $TEMPLATE \\]]/a subjectAltName = @subjectAltName" -e '$a [ subjectAltName ]' $CONF_TMP
        #sed -i "/\[ $TEMPLATE \]/a subjectAltName = @subjectAltName" -i '$a [ subjectAltName ]' $CONF_TMP
        
        
        for s in "${ALTSUBJECT[@]}"
        do
            if [[ $s =~ ^([0-9]+\.){3}[0-9]+$ || $s =~ ^[0-9\:]+$ ]]; then
                let ip++
                sed -i "/\[ subjectAltName \]/a IP.$ip=$s" $CONF_TMP
            else
                let dns++
                sed -i "/\[ subjectAltName \]/a DNS.$dns=$s" $CONF_TMP
            fi
        done
    fi

    # cert generation
    openssl req -new -extensions $TEMPLATE -key tmp.key -passin pass:$PASSWD -config $CONF_TMP -utf8 -out tmp.csr
else
    cp "reqs/$FILENAME.csr" tmp.csr
fi

[[ $ONLY_REQUEST == 0 ]] && openssl ca -config $CONF_TMP -days 3650 -extensions $TEMPLATE -keyfile $CA_KEY -key $PASSWD -cert $CA_CERT -in tmp.csr -out "$FILENAME.crt"

if [[ $ONLY_REQUEST == 0 && ! -f "$FILENAME.crt" || ! -f tmp.key || ! -f tmp.csr ]]; then
    echo 'Something wrong, stopping here'
    exit 3
fi

[[ $ONLY_REQUEST == 0 ]] && mv "$FILENAME.crt" "certs/$FILENAME.crt"
mv tmp.key "keys/$FILENAME.key"
mv tmp.csr "reqs/$FILENAME.csr"

Une seule ligne change en fonction de si l’on souhaite un certificat serveur ou client, c’est celle où la signature du certificat intervient avec le paramètre -extensions, qui indique ou bien la section x509_server pour un certificat serveur, ou bien la section x509_client pour un certificat client.

Les certificats EV

Ces certificats EV, ou extended validation, sont un peu spéciaux, précieux même en ce sens que du point de vue de l’utilisateur, cela rend la barre du navigateur vert, alors qu’avec les autres certificats, le navigateur se content de rendre la barre bleue ou bien d’afficher uniquement un cadenas.

ll s’agit de certificats ayant des informations supplémentaires, tout comme le certificat d’autorité qui l’a émis, dont certains non-référencé par OpenSSL d’ailleurs, donc à rajouter manuellement dans la section new_oids . Mais la liste des certificats d’autorité pouvant émettre des certificats EV est codé en dur dans chaque navigateur, donc même s’il est possible de forger un certificat en tout point identique au niveau de la liste des attributs contenus à un vrai certificat EV, y compris pour un usage interne, le navigateur ne reconnaîtra jamais un certificat que vous avez créée comme EV, il sera considéré comme un certificat standard.

Les autres script

Afin de parfaire l’autorité, d’autres script sont nécessaires.

Script de génération de la liste de révocation de certificats

Cette liste, aussi appelée plus simplement CRL, publiquement accessible et signée, permet d’informer sur les certificats qui ont été prématurément révoqués, pour une raison ou une autre (certificat compromis, perdu, clé privée faible, révocation d’accès…)

#!/bin/bash

# password
stty -echo
read -p 'Please enter the master password: ' PASSWD;echo
stty echo

openssl ca -gencrl -crldays 365 -keyfile ca.key -key $PASSWD -cert ca.crt -config kveer.openssl.cnf | openssl crl -outform DER -out crl.crl

Une fois la liste générée, il faudra la rendre accessible à l’adresse qui a été spécifiée par le paramètre crlDistributionPoints . Ce paramètre est présent dans chaque certificat généré, c’est de cette manière que le point de communication est connu. A noté que la CRL ne contenant que les numéros de séries des certificats émis par une autorité de certification, une CRL est propre à chaque autorité de certification.

Tout comme un certificat, la CRL a une durée de vie, ici fixé à 1 an, dont le but est de forcer régulièrement le programme à récupérer la version à jour de la liste des certificats périmés. Surtout qu’un certificat révoqué ne se fait que lorsqu’un problème de sécurité est rencontré, il est impératif que l’information se propage au plus vite afin de limiter l’utilisation abusive d’un certificat périmé.

Remarque : tous les certificats émis par une même autorité de certification doivent avoir la même adresse dans le paramètre crlDistributionPoints .

Corollaire 1 : Pour une autorité de certification intermédiaire B, son point de distribution est donc celle décidé par l’autorité A ayant signée le certificat intermédiaire, dans mon cas, c’est donc l’autorité racine.

Corollaire 2 : Une autorité intermédiaire B ne peut pas se révoquer elle-même. Son autorité parente A peut révoquer  B et publier un nouveau CRL signé avec la clé de l’autorité parente A.

Script de révocation

Ce script permet de révoquer un certificat émis, c’est-à-dire le rendre invalide avant sa date d’expiration.

#!/bin/bash

CA_KEY=ca.key
CA_CERT=ca.crt
CONF_CA=kveer.openssl.cnf

if [[ ! (-f ca.key && -f ca.crt) ]]; then
    echo 'There is no CA root'
    exit 1
fi

if [[ $# < 1 ]]; then
    echo "$0 certificate_name"
    exit 2
fi

# password
stty -echo
read -p 'Please enter the master password: ' PASSWD;echo
stty echo

openssl ca -revoke certs/$1.crt -keyfile ca.key -key $PASSWD -cert ca.crt -config $CONF_CA

Ce script est inutile tout seul. Une fois un certificat révoqué, la CRL doit être mise à jour afin de publier l’information.

Script d’export au format PFX

Le seul moyen d’importer un certificat avec sa clé privée dans un magasin de certificats géré par Windows est de passer par un package PFX ou PKCS12. Il s’agit d’un standard permettant d’échanger du contenu sensible à l’aide d’un mot de passe.

#!/bin/bash

#cannot user ecdsa because windows nt 5.x does not support it yet.
# prime192v1 seems to be the best one otherwise.

if [[ $# < 1 ]]; then
    echo "$0 certificate_name"
    exit 2
fi

# Generate a random password
#  $1 = number of characters; defaults to 32
#  $2 = include special characters; 1 = yes, 0 = no; defaults to 1
function randpass {
    [ "$2" == "0" ] && CHAR="[:alnum:]" || CHAR="[:graph:]"
    cat /dev/urandom | tr -cd "$CHAR" | head -c ${1:-32}
}

EXPORT=$(randpass 20 0)
echo "Note this import password somewhere:" $EXPORT

# password
stty -echo
read -p "Please enter the password: " PASSWD;echo
stty echo

cat "keys/$1.key" "certs/$1.crt" ca.crt ../ca.crt | openssl pkcs12 -export -name "$1" -passout pass:$EXPORT -passin pass:$PASSWD -aes192 -out "certs/$1.pfx" -CAfile ca.crt

Il suffit de spécifier le nom d’un certificat précédemment généré et le script génère le PFX ainsi que son mot de passe de manière aléatoire.