PKIX path building failed: unable to find valid certification path to requested target... À tes souhaits !

TLS (Transport Layer Security), plus souvent dénommé SSL (Secured Sockets Layer - son prédécesseur déprécié), est l'un des protocoles les plus utilisés sur le Web pour sécuriser les sites et les applications qui le peuplent. TLS repose à la fois sur des algorithmes de cryptographie asymétrique (clé publique et clé privée) et une relation de confiance avec des autorités reconnues à travers le monde, en se basant sur un système de certificats sécurisés.

Cependant, ce modèle ne fonctionne pas lorsque vous êtes enfermé derrière le proxy d'une entreprise, qui ne souhaite pas (à raison ?) que les applications qu'elle développe se mettent à transmettre (par erreur, dans le meilleur des cas !) des informations confidentielles ou qu'elles soient des portes ouvertes pour la plus grande joie des mauvaises intentions du Net 😱

Néanmoins, à l'intérieur de cette entreprise, comme il est nécessaire de sécuriser la communication sur le réseau, il faut passer par TLS. Mais cette fois, l'entreprise va se déclarer elle-même autorité de certificat, en exposant en interne un certificat dit "auto-signé". C'est là que les ennuis commencent !

1/ Vous lancez Maven ou SBT... Et boom ! ça crashe, parce que ça ne passe pas le pare-feu. On vous dit alors de passer par un proxy Maven (Nexus ou Artifactory).

2/ Vous configurez vos outils. Vous retentez... Et boom ! Vous avez une exception à peine compréhensible qui parle de certification, de Sun et de PKIX. On vous dit alors qu'il y a un vague JDK, qui a été correctement configuré avec tous les certificats et qui traine quelque part sur le réseau interne.

3/ Vous récupérez et installez ce JDK. Vous retentez... Et boom ! Vous obtenez la même erreur que la dernière fois. À ce moment là, vous commencez à avoir du mal à faire la distinction entre TLS et la magie. Et vous êtes sur le point de friser la folie 🤯

Reproduction du cas

Commençons par générer un certificat auto-signé que nous allons placer dans un keystore, nommé ici selfsigned.jks.

$ keytool -genkey \
    -alias selfsigned -keyalg RSA \
    -keypass changeit -storepass changeit \
    -keystore tmp/certificate/selfsigned.jks

What is your first and last name?
  [Unknown]:  François Sarradin
What is the name of your organizational unit?
  [Unknown]:
What is the name of your organization?
  [Unknown]:  Univalence
What is the name of your City or Locality?
  [Unknown]:  Paris
What is the name of your State or Province?
  [Unknown]:
What is the two-letter country code for this unit?
  [Unknown]:  fr
Is
  CN=François Sarradin, OU=Unknown, O=Univalence, L=Paris, ST=Unknown, C=fr
correct?
  [no]:  yes

Nous allons utiliser ce keystore pour monter un faux service Web sécurisé. Pour cela nous créons d'abord un contexte SSL, à travers une fonction qui prend en paramètres le chemin vers ce keystore ainsi que le mot de passe associé.

import java.io.FileInputStream
import java.security.KeyStore
import javax.net.ssl.{KeyManagerFactory, SSLContext, TrustManagerFactory}

def createSSLContext(keystorePath: String, password: String): SSLContext = {
    val keyStore = KeyStore.getInstance("JKS")
    keyStore.load(new FileInputStream(keystorePath), password.toCharArray)

    val keyManagerFactory = KeyManagerFactory.getInstance("SunX509")
    keyManagerFactory.init(keyStore, password.toCharArray)
    val keyManagers = keyManagerFactory.getKeyManagers

    val trustManagerFactory = TrustManagerFactory.getInstance("SunX509")
    trustManagerFactory.init(keyStore)
    val trustManagers = trustManagerFactory.getTrustManagers

    val sslContext = SSLContext.getInstance("TLS")
    sslContext.init(keyManagers, trustManagers, null)

    sslContext
  }

Il faut ensuite créer une configuration pour le service Web avec ce contexte.

import com.sun.net.httpserver._

    val httpsConfigurator =
      new HttpsConfigurator(sslContext) { self =>
        override def configure(params: HttpsParameters): Unit = {
          val c = self.getSSLContext
          val engine = c.createSSLEngine
          params.setNeedClientAuth(false)
          params.setCipherSuites(engine.getEnabledCipherSuites)
          params.setProtocols(engine.getEnabledProtocols)
          params.setSSLParameters(c.getDefaultSSLParameters)
        }
      }

Et on lance le service.

import com.sun.net.httpserver._
import java.net.InetSocketAddress

    val server = HttpsServer.create(new InetSocketAddress("0.0.0.0", 9443), 0)
    server.setHttpsConfigurator(httpsConfigurator)
    server.createContext("/", (exchange: HttpExchange) => {
      val content = "Hello"
      val length = content.length
      val raw = content.getBytes

      exchange.sendResponseHeaders(200, length)
      exchange.getResponseBody.write(raw)

      exchange.close()
    })
    server.start()

On va maintenant se créer un client Web.

import java.net.URL

object Main {
  def main(args: Array[String]): Unit = {
    val result = new URL("https://127.0.0.1:9443/").getContent()

    println(result)
  }
}

En lançant le service Web, puis le client, nous obtenons alors l'exception ci-dessous.

Exception in thread "main" javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at java.base/sun.security.ssl.Alert.createSSLException(Alert.java:128)
	at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:321)
	at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:264)
	at java.base/sun.security.ssl.TransportContext.fatal(TransportContext.java:259)
	at java.base/sun.security.ssl.CertificateMessage$T13CertificateConsumer.checkServerCerts(CertificateMessage.java:1329)
	at java.base/sun.security.ssl.CertificateMessage$T13CertificateConsumer.onConsumeCertificate(CertificateMessage.java:1204)
	at java.base/sun.security.ssl.CertificateMessage$T13CertificateConsumer.consume(CertificateMessage.java:1151)
	at java.base/sun.security.ssl.SSLHandshake.consume(SSLHandshake.java:392)
	at java.base/sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:444)
	at java.base/sun.security.ssl.HandshakeContext.dispatch(HandshakeContext.java:421)
	at java.base/sun.security.ssl.TransportContext.dispatch(TransportContext.java:178)
	at java.base/sun.security.ssl.SSLTransport.decode(SSLTransport.java:164)
	at java.base/sun.security.ssl.SSLSocketImpl.decode(SSLSocketImpl.java:1152)
	at java.base/sun.security.ssl.SSLSocketImpl.readHandshakeRecord(SSLSocketImpl.java:1063)
	at java.base/sun.security.ssl.SSLSocketImpl.startHandshake(SSLSocketImpl.java:402)
	at java.base/sun.net.www.protocol.https.HttpsClient.afterConnect(HttpsClient.java:567)
	at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:185)
	at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1581)
	at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1509)
	at java.base/java.net.URLConnection.getContent(URLConnection.java:749)
	at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getContent(HttpsURLConnectionImpl.java:425)
	at java.base/java.net.URL.getContent(URL.java:1131)
	at io.univalence.test_ssl.Main$.main(Main.scala:7)
	at io.univalence.test_ssl.Main.main(Main.scala)
Caused by: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at java.base/sun.security.validator.PKIXValidator.doBuild(PKIXValidator.java:385)
	at java.base/sun.security.validator.PKIXValidator.engineValidate(PKIXValidator.java:290)
	at java.base/sun.security.validator.Validator.validate(Validator.java:264)
	at java.base/sun.security.ssl.X509TrustManagerImpl.validate(X509TrustManagerImpl.java:321)
	at java.base/sun.security.ssl.X509TrustManagerImpl.checkTrusted(X509TrustManagerImpl.java:221)
	at java.base/sun.security.ssl.X509TrustManagerImpl.checkServerTrusted(X509TrustManagerImpl.java:129)
	at java.base/sun.security.ssl.CertificateMessage$T13CertificateConsumer.checkServerCerts(CertificateMessage.java:1313)
	... 19 more
Caused by: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
	at java.base/sun.security.provider.certpath.SunCertPathBuilder.build(SunCertPathBuilder.java:141)
	at java.base/sun.security.provider.certpath.SunCertPathBuilder.engineBuild(SunCertPathBuilder.java:126)
	at java.base/java.security.cert.CertPathBuilder.build(CertPathBuilder.java:297)
	at java.base/sun.security.validator.PKIXValidator.doBuild(PKIXValidator.java:380)
	... 25 more

Analyse

Voici le message :

Exception in thread "main" javax.net.ssl.SSLHandshakeException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

L'exception est de type javax.net.ssl.SSLHandshakeException. En effet, elle intervient lors de la période de négociation avec le service Web. C'est à ce moment que sont convenus le protocole utilisé, sa version, l'algorithme de cryptographie utilisé... C'est aussi à ce moment que le service transmet son certificat, permettant ainsi au client d'effectuer les vérifications qui lui semblent nécessaires.

Les certificats utilisés ici sont des certificats à clé publique basé sur la norme X.509. Cette norme repose sur un algorithme permettant de vérifier qu'un certificat est lié à une autorité de certification et "signé" par celle-ci en utilisant sa clé privée. Or, dans notre cas, le certificat envoyé par notre service Web ne repose sur aucune autorité de certification. De plus, le site du service n'est pas reconnu comme autorité de certification par le client Web.

Les certificats reconnus sont stockés dans un keystore. Dès qu'une connexion TLS est mise en place, à travers la classe sun.security.ssl.TrustStoreManager.TrustStoreDescriptor (en Java 11), Java va d'abord rechercher le fichier keystore à travers la propriété javax.net.ssl.trustStore, si elle a été définie. Sinon, Java va vérifier que le fichier $JAVA_HOME/lib/security/jssecacerts existe et de même avec le fichier $JAVA_HOME/lib/security/cacerts. Normalement, ce dernier existe et contient les certificats des autorités reconnues. jssecacerts sert pour des besoins en développement et permet de ne pas "polluer" cacerts.

Ainsi, pour faire fonctionner notre connexion TLS avec notre service Web, nous allons devoir récupérer le certificat de ce service et l'ajouter à l'un des keystores principaux.

Résolution

Si vous avez OpenSSL de disponible, la commande suivante permet de récupérer le certificat associé à notre service Web (vous pouvez d'ailleurs tenter la commande avec google.com:443).

echo -n \
    | openssl s_client -connect 127.0.0.1:9443 \
    | openssl x509 -outform PEM \
    > selfsigned_certificate.pem

Sinon, passez par votre navigateur. Il faut vous rendre sur le site du service et afficher les informations de la page visitée. Si votre navigateur vous le permet, vous pourrez télécharger/exporter le certificat associé.

Importez alors le certificat dans le keystore Java.

$JAVA_HOME/bin/keytool -import \
    -alias selfsigned -file selfsigned_certificate.pem \
    -keystore $JAVA_HOME/lib/security/jssecacerts

Vérifiez que l'import s'est bien passé.

$JAVA_HOME/bin/keytool -list -v \
    -alias selfsigned \
    -keystore $JAVA_HOME/lib/security/jssecacerts

Le certificat du service Web devrait s'afficher.

Pour le JDK8, il vous faudra utiliser $JAVA_HOME/jre/lib/security (attention au jre) comme chemin vers les fichiers keystore gérés par Java. Il existe aussi une procédure dédiée à IntelliJ IDEA décrite sur le site support.