TLS: sujets de certificats, ASN.1 et pétage de plomb
Je suis récemment tombé sur un problème au travail qui je pense mérite son article de blog. Cela concerne le TLS et plus particulièrement la validation des certificats clients en mutual TLS.
Le problème
Ce que je voulais faire était assez simple sur le papier.
Je fournissais à un programme une autorité de certification (certificat, clé publique et privée) et ce programme générait des certificats clients qui allaient être ensuite utilisés pour se connecter à un logiciel écrit en Golang (ce dernier acceptant les certificats clients générés par cette autorité).
L’autorité de certification était générée par cfssl, un outil bien connu aujourd’hui pour ce genre de tâche.
Le programme générant les certificats clients était lui écrit en Clojure, et utilisait la librairie jvm-ssl-utils, cette dernière étant un wrapper autour de la librairie Java bouncycastle.
Récapitulons: j’ai donc un outil en Golang (cfssl) qui me génère une autorité de certification. Cette autorité est utilisée depuis un programme Clojure pour générer des certificats clients pour autoriser des clients à se connecter en mutual TLS à une application Golang.
Sur le papier, cela fonctionne. Mettons ça en pratique.
En pratique
Autorité de certification
Générons d’abord une autorité de certification en utilisant cfssl. Cela se fait rapidement, vous pouvez utiliser la documentation de CoreOS qui explique cela par exemple.
La façon de faire n’est pas importante, vous pouvez sauter cette partie (et aller directement à la section J’ai quoi maintenant ?
) si vous ne voulez pas essayer de reproduire le problème, mais voici la procédure tirée du site de CoreOS:
-
créez un fichier nommé
ca-csr.json
ayant pour contenu:
{
"CN": "mcorbin.fr",
"key": {
"algo": "rsa",
"size": 2048
},
"names": [
{
"C": "FR",
"L": "Meuse",
"O": "mcorbin.fr",
"OU": "blog"
}
]
}
On remarque qu’on configure le subject (country, location…) de notre CA. Générez votre ca avec:
cfssl gencert -initca ca-csr.json | cfssljson -bare ca -
-
Créez un fichier nommé
ca-config.json
ayant pour contenu:
{
"signing": {
"default": {
"expiry": "43800h"
},
"profiles": {
"server": {
"expiry": "43800h",
"usages": [
"signing",
"key encipherment",
"server auth",
"client auth"
]
}
}
}
}
Ici, on configure la façon dont nos certificats seront générés.
Certificat serveur
Nous allons maintenant générer des certificats pour notre partie serveur (qui seront utilisés par notre application finale écrite en Golang):
cfssl print-defaults csr > server.json
Vous pouvez modifier server.json pour configurer votre futur certificat comme vous le voulez (notamment la partie hosts
ou CN
), par exemple:
{
"CN": "example.net",
"hosts": [
"localhost",
"www.example.net"
],
"key": {
"algo": "rsa",
"size": 2048
},
"names": [
{
"C": "US",
"ST": "CA",
"L": "San Francisco"
}
]
}
Générez maintenant vos certificats serveur:
cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=server server.json | cfssljson -bare server
Et voilà, vos certificats serveur sont générés.
Certificats Clients
J’utilise maintenant mon autorité de certification pour générer des certificats clients comme expliqué précédemment (depuis du code Clojure en utilisant la lib jvm-ssl-utils).
Le code est assez complexe, donc je ne le détaillerai pas ici. Faites moi confiance, ça marche ;)
J’ai quoi maintenant ?
J’ai donc maintenant:
-
Une autorité de certification
-
Des certificats serveur, pouvant être utilisés par un serveur voulant faire du TLS.
-
Des certificats clients, signés par l’autorité de certification, me permettant donc en théorie de me connecter sur le serveur.
Tester la théorie
Depuis un serveur Clojure
Pour tester ma théorie, j’ai démarré un serveur Aleph en utilisant les certificats serveur générés précédemment. Le code pour configurer le TLS pour Aleph (et donc générer un SSLContext pour Netty) n’est pas très intéressant, je ne le présenterai donc pas ici.
J’utilise ensuite les certificats clients (générés depuis la lib Clojure) avec curl
pour envoyer des requêtes au server. Ca marche !
curl --cacert ca.pem --cert clj-client.pem --key clj-client.key https://localhost:9999
hello
Depuis un serveur Golang
Essayons la même chose depuis un serveur golang. Par exemple, démarrons etcd en utilisant les mêmes certificats serveur que notre serveur Clojure:
./etcd \
--cert-file=server.pem \
--key-file=server-key.pem \
--trusted-ca-file=ca.pem \
--client-cert-auth \
--listen-client-urls https://localhost:2379 \
--advertise-client-urls https://localhost:2379
Réessayons notre commande curl:
curl --cacert ca.pem --cert clj-client.pem --key clj-client.key https://localhost:2379
curl: (35) error:14094412:SSL routines:ssl3_read_bytes:sslv3 alert bad certificate
Et on remarque dans les logs du serveur etcd:
2020-11-05 22:02:35.562419 I | embed: rejected connection from "127.0.0.1:40436" (error "tls: failed to verify client's certificate: x509: issuer name does not match subject from issuing certificate", ServerName "localhost")
Issuer name does not match subject from issuing certificate
Donc nos certificats fonctionnent depuis un serveur Java mais pas depuis un serveur Golang. Surprenant non ?
L’erreur dans les logs etcd est assez claire: le champ Issuer (l’autorité ayant généré le certificat client) ne correspond pas au Subject de cette même autorité !
Premier réflexe: vérifier cela:
openssl x509 -in ca.pem -noout -text
Issuer: C = FR, L = Meuse, O = mcorbin.fr, OU = blog, CN = mcorbin.fr
Validity
Not Before: Nov 4 18:30:00 2020 GMT
Not After : Nov 3 18:30:00 2025 GMT
Subject: C = FR, L = Meuse, O = mcorbin.fr, OU = blog, CN = mcorbin.fr
openssl x509 -in clj-client.pem -noout -text
Issuer: C = FR, L = Meuse, O = mcorbin.fr, OU = blog, CN = mcorbin.fr
Validity
Not Before: Nov 3 23:14:51 2020 GMT
Not After : Sep 17 09:30:42 2070 GMT
Subject: C = FR, L = Meuse, O = blog, OU = mcorbin.fr, CN = client
Pourtant, on voit ici que le champ Issuer du certificat client (clj-client.pem
) correspond bien au Subject de notre autorité de certification (ca.pem
): la valeur est bien C = FR, L = Meuse, O = mcorbin.fr, OU = blog, CN = mcorbin.fr
partout.
Et surtout, je rappelle, nos certificats marchent depuis un programme Java ! Là, c’est le moment où je commençais à devenir fou.
Jusqu’à ce qu’une collègue trouve la solution.
ASN.1
Les certificats sont encodés en ASN.1.
Je ne rentrerai pas dans le détail d’ASN.1 dans cet article (j’ai un petit parser ASN.1 écrit en Clojure qui fonctionne pas trop mal, ce serait une bonne base pour un article dédié sur ce format), mais en gros ASN.1 permet de représenter des données en indiquant pour chaque donnée son type et sa taille en byte (et donc cela permet de récupérer sa valeur).
On pourrait par exemple avoir une représentation textuelle (après parsing) qui ressemblerait à
[TYPE: SEQUENCE,
taille: 892,
valeur:
[
[TYPE: Integer, taille 1, valeur 2]]
[TYPE: Integer, taille 1, valeur 3]]
...
]
]
Mais revenons à nos certificats.
On a donc dans ASN.1 des données qui ont chacune un type. OpenSSL permet récupérer cette information pour les champs Issuer/Subject d’un certificat. Regardons cela pour le certificat de notre autorité de certification:
openssl x509 -in ca.pem -subject -issuer -nameopt multiline,show_type -noout -subject_hash -issuer_hash
subject=
countryName = PRINTABLESTRING:FR
localityName = PRINTABLESTRING:Meuse
organizationName = PRINTABLESTRING:mcorbin.fr
organizationalUnitName = PRINTABLESTRING:blog
commonName = PRINTABLESTRING:mcorbin.fr
issuer=
countryName = PRINTABLESTRING:FR
localityName = PRINTABLESTRING:Meuse
organizationName = PRINTABLESTRING:mcorbin.fr
organizationalUnitName = PRINTABLESTRING:blog
commonName = PRINTABLESTRING:mcorbin.fr
Faisons la même chose pour notre certificat client, généré depuis Clojure:
openssl x509 -in clj-client.pem -subject -issuer -nameopt multiline,show_type -noout -subject_hash -issuer_hash
subject=
countryName = PRINTABLESTRING:FR
localityName = UTF8STRING:Meuse
organizationName = UTF8STRING:blog
organizationalUnitName = UTF8STRING:mcorbin.fr
commonName = UTF8STRING:client
issuer=
countryName = PRINTABLESTRING:FR
localityName = UTF8STRING:Meuse
organizationName = UTF8STRING:mcorbin.fr
organizationalUnitName = UTF8STRING:blog
commonName = UTF8STRING:mcorbin.fr
Et voici le problème: les valeurs sont les mêmes, mais l'encodage est différent. Et selon les implémentations de TLS, cela peut poser ou non des problèmes.
Le type PrintableString permet de représenter un sous ensemble de ASCII, alors qu’UTF8String permet de représenter comme son nom l’indique de l’UTF8.
On a donc l’outillage Golang (cfssl) qui nous a généré une autorité de certification avec le champ Subject en PrintableString (à part le champ countryName), et l’outillage Clojure qui a généré un certificat client en UTF8String à partir de cette même autorité !
L’implémentation TLS de Golang
Le code source Golang nous montre en effet que le langage fait une comparaison stricte sur les champs Subject et Issuer des certificats. Regardez ici:
if !bytes.Equal(child.RawIssuer, c.RawSubject) {
return CertificateInvalidError{c, NameMismatch, ""}
}
On compare donc ici RawIssuer et RawSubject entre eux, les deux variables étant des tableaux de bytes. Ecrivons un petit programme Golang qui nous permet d’afficher ces valeurs pour nos certificats (autorité et client):
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io/ioutil"
)
func main() {
caCert, _ := ioutil.ReadFile("ca.pem")
caKey, _ := ioutil.ReadFile("ca-key.pem")
caCertificate, _ := tls.X509KeyPair(caCert, caKey)
caX509cert, _ := x509.ParseCertificate(caCertificate.Certificate[0])
fmt.Println(caX509cert.RawIssuer)
clientCert, _ := ioutil.ReadFile("clj-client.pem")
clientKey, _ := ioutil.ReadFile("clj-client.key")
clientCertificate, _ := tls.X509KeyPair(clientCert, clientKey)
clientX509cert, _ := x509.ParseCertificate(clientCertificate.Certificate[0])
fmt.Println(clientX509cert.RawIssuer)
}
On le lance, et on obtient:
[48 86 49 11 48 9 6 3 85 4 6 19 2 70 82 49 14 48 12 6 3 85 4 7 **19** 5 77 101 117 115 101 49 19 48 17 6 3 85 4 10 **19** 10 109 99 111 114 98 105 110 46 102 114 49 13 48 11 6 3 85 4 11 **19** 4 98 108 111 103 49 19 48 17 6 3 85 4 3 **19** 10 109 99 111 114 98 105 110 46 102 114]
[48 86 49 11 48 9 6 3 85 4 6 19 2 70 82 49 14 48 12 6 3 85 4 7 **12** 5 77 101 117 115 101 49 19 48 17 6 3 85 4 10 **12** 10 109 99 111 114 98 105 110 46 102 114 49 13 48 11 6 3 85 4 11 **12** 4 98 108 111 103 49 19 48 17 6 3 85 4 3 **12** 10 109 99 111 114 98 105 110 46 102 114]
Comme vous pouvez le voir, certaines valeurs (entre **) sont différentes.
Parfois des 12, parfois des 19. C’est en effet ces valeurs qui donnent le type de donnée utilisé en ASN.1: 12 pour UTF8String, 19 pour PrintableString.
A part ça, le reste est identique mais cela suffit à faire échouer la comparaison, et générer l’erreur montrée précédemment.
Corriger le problème
Il y a plusieurs moyens de corriger ce problème.
Utiliser le même encodage partout
La solution la plus simple est de générer tous les certificats avec le même encodage pour le subject et l’issuer. Facile à dire, mais pas facile à réaliser.
Bizarrement, il semble impossible de choisir l’encodage voulu avec l’ensemble de l’écosystème Golang (cfssl, Hashicorp Vault…). L’écosystème Go réalise les choses de cette façon:
-
Tout générer en PrintableString si possible.
-
Si un caractère n’est pas valide en PrintableString, générer le champ en UTF8String. Par exemple, si je reprends mon autorité de certification générée par cfssl et remplace la location par
"L": "éééé",
mon certificat auralocalityName = UTF8STRING:\E9\E9\E9\E9
, mais les autres champs seront en PrintableString.
Je ne comprends pas comment ce genre d’outils avancés et utilisés partout ne permettent pas de faire les choses de façon plus intelligente. Ou alors j’ai raté quelque chose dans les documentations, dans ce cas contactez moi (car j’ai quand même du mal à y croire donc je me dis que j’ai dû manquer un truc).
En Java, BouncyCastle semble rendre paramétrable l’encodage des champs via la classe abstraite X509NameEntryConverter. La Javadoc résume tout:
/**
* It turns out that the number of standard ways the fields in a DN should be
* encoded into their ASN.1 counterparts is rapidly approaching the
* number of machines on the internet. By default the X509Name class
* will produce UTF8Strings in line with the current recommendations (RFC 3280).
* <p>
**/
Mais cela n’était pas évident à intégrer dans le wrapper Clojure.
Sinon, il faut utiliser de l’outillage compatible. Par exemple, remplacer cfssl par OpenSSL (qui lui génère tout par défaut en UTF8String) nous a permis de résoudre le soucis dans mon cas.
Les RFCs disent quoi ?
RFC 3280 (2002):
The DirectoryString type is defined as a choice of PrintableString,
TeletexString, BMPString, UTF8String, and UniversalString. The
UTF8String encoding [RFC 2279] is the preferred encoding, and all
certificates issued after December 31, 2003 MUST use the UTF8String
encoding of DirectoryString (except as noted below).
C’est clair au moins, il faut utiliser UTF8String.
Problème, la RFC 5280 de 2008 ne mentionne plus cette partie. Pas si clair que ça en fait. En tout cas, à part l’outillage Golang, le reste du monde (Java, OpenSSL) a l’air de faire de l’UTF8String par défaut.
Une issue parlant du problème sur le projet Github de Golang contient également une discussion intéressante.
Conclusion
Mon plus gros problème comme dit précédemment est le fait que ce réglage ne puisse pas être choisi dans l’écosystème Golang.
Donc si vous générez des certificats en utilisant plusieurs outils, méfiance !
Add a comment
If you have a bug/issue with the commenting system, please send me an email (my email is in the "About" section).