Historique d'une seule négociation SSL

Bonjour, Habr!

Récemment, j'ai dû visser SSL avec authentification mutuelle au Spring Reactive Webclient. Il semblerait que ce soit simple, mais cela a entraîné une errance dans les sources du JDK avec une fin inattendue. L'expérience a été acquise pour un article entier, qui peut être utile aux ingénieurs dans les tâches quotidiennes ou dans la préparation d'un entretien.


Énoncé du problème


Il existe un service REST côté client qui fonctionne via HTTPS.
Vous devez y accéder à partir d'une application Java cliente.

La première chose qui m'a été donnée dans cette quête était 2 fichiers avec l'extension .pem - un certificat client et une clé privée. J'ai vérifié leurs performances à l'aide de Postman: je leur ai spécifié les chemins d'accès dans les paramètres et, après avoir extrait une demande, j'ai vérifié que le serveur répond avec 200 OK et un corps de réponse significatif. Séparément, j'ai vérifié que sans certificat client, le serveur renvoie un état HTTP de 500 et un court message dans le corps de la réponse qu'une "exception de sécurité" s'est produite avec un code spécifique.

Ensuite, vous devez configurer correctement l'application Java cliente.

Pour les demandes REST, j'ai utilisé Spring Reactive WebClient avec des E / S non bloquantes.
La documentation contient un exemple de la façon dont il peut être personnalisé en lui lançant un objet SslContext, qui stocke uniquement les certificats et les clés privées.

Ma configuration dans une version simplifiée était presque du copier-coller de la documentation:

SslContext sslContext = SslContextBuilder .forClient() .keyManager(…) /*  ,   .pem      (, , ). PEM      . /    openssl .     KeyManagerFactory. */ .build(); ClientHttpConnector connector = new ReactorClientHttpConnector( builder -> builder.sslContext(sslContext)); WebClient webClient = WebClient.builder() .clientConnector(connector).build(); 

En suivant le principe de TDD, j'ai également écrit un test qui utilise WebTestClient au lieu de WebClient, qui affiche un tas d'informations de débogage. La toute première affirmation était la suivante:

 webTestClient .post() .uri([  ]) .body(BodyInserters.fromObject([ ,   ,  ])) .exchange() .expectStatus().isOk() 

Ce test simple n'a pas réussi immédiatement: le serveur en a renvoyé 500 avec le même corps que si Postman n'avait pas spécifié de certificat client.

Séparément, je note que lors du débogage, j'ai activé l'option "ne pas vérifier le certificat du serveur", à savoir, j'ai transmis l'instance InsecureTrustManagerFactory en tant que TrustManager pour SslContext. Cette mesure était redondante, mais excluait à coup sûr la moitié des options.

Les informations de débogage dans le test n'ont pas mis en lumière le problème, mais il semblait que quelque chose n'allait pas au stade de la négociation SSL, j'ai donc décidé de comparer plus en détail la façon dont la connexion se produit dans les deux cas: pour Postman et pour le client Java. Tout cela peut être vu en utilisant Wireshark - c'est un analyseur de trafic réseau très populaire. En même temps, j'ai vu comment la prise de contact SSL se produit avec l'authentification bidirectionnelle, pour ainsi dire, en direct (ils aiment le demander lors des entretiens):

  • Tout d'abord, le client envoie un message Client Hello contenant des méta-informations comme la version du protocole et la liste des algorithmes de cryptage qu'il prend en charge
  • En réponse, le serveur envoie immédiatement un paquet des messages suivants: Server Hello, Certificat, Server Key Exchange, Certificate Request, Server Hello Done .
    Server Hello indique l'algorithme de chiffrement sélectionné par le serveur (suite de chiffrement). À l'intérieur du certificat se trouve le certificat du serveur. L'échange de clés de serveur contient certaines informations nécessaires au chiffrement, selon l'algorithme choisi (nous ne sommes pas intéressés par les détails maintenant, nous supposons donc que ce n'est qu'une clé publique, bien que ce soit incorrect!). De plus, dans le cas d'une authentification bidirectionnelle, dans le message Demande de certificat , le serveur fait une demande de certificat client et explique les formats qu'il prend en charge et les émetteurs auxquels il fait confiance.
  • Après avoir reçu ces informations, le client vérifie le certificat du serveur et envoie son certificat, sa «clé publique» et d'autres informations dans les messages suivants: certificat, échange de clés client, vérification de certificat . Le dernier est le message ChangeCipherSpec , indiquant que toute communication ultérieure se fera sous forme cryptée
  • Enfin, après tous ces brassages, le serveur vérifiera le certificat du client et, si tout va bien, il donnera une réponse.

Après quinze minutes de blocage dans le trafic, j'ai remarqué que le client Java, en réponse à la demande de certificat du serveur, pour une raison quelconque n'envoie pas son certificat, contrairement au client Postman. Autrement dit, il y a un message de certificat, mais il est vide.

Ensuite, j'aurais besoin de regarder d'abord la spécification du protocole TLS , qui dit littéralement ce qui suit:
Si la liste certificate_authorities du message de demande de certificat n'était pas vide, l'un des certificats de la chaîne de certificats DEVRAIT être émis par l'une des autorités de certification répertoriées.
Nous parlons de la liste certificate_authorities spécifiée dans le message de demande de certificat provenant du serveur. Un certificat client (au moins une des chaînes) doit être signé par l'un des émetteurs répertoriés dans cette liste. Nous appelons cela le chèque X.

Je ne connaissais pas cette condition et l'ai trouvée lorsque j'ai atteint les profondeurs du JDK dans le débogage (je l'ai JDK9). Netty's HttpClient, qui sous-tend Spring WebClient, utilise SslEngine du JDK par défaut. Alternativement, vous pouvez le basculer vers le fournisseur OpenSSL en ajoutant les dépendances nécessaires, mais je n'ai finalement pas eu besoin de cela.
J'ai donc défini des points d'arrêt dans la classe sun.security.ssl.ClientHandshaker et dans le gestionnaire du message serverHelloDone, j'ai trouvé une vérification X qui n'a pas réussi: aucun des émetteurs de la chaîne de certificats client ne figurait dans la liste des émetteurs auxquels le serveur fait confiance ( du message de demande de certificat du serveur).

Je me suis tourné vers le client pour un nouveau certificat, mais le client a objecté que tout fonctionnait bien pour lui, et a remis à Python un script, avec lequel il vérifiait généralement les certificats pour la fonctionnalité. Le script n'a rien fait de plus que d'envoyer une demande HTTPS à l'aide de la bibliothèque Requêtes et a renvoyé 200 OK. J'ai finalement été surpris lorsque la bonne vieille boucle a également retourné 200 OK. Je me suis tout de suite souvenue de la blague: "Toute la société est en décalage, un lieutenant fait un pas dans la jambe."

Curl est, bien sûr, un utilitaire réputé, mais la norme TLS n'est pas non plus un morceau de papier toilette. Ne sachant pas quoi vérifier, j'ai grimpé sans but autour de la documentation de curl, et sur Github, où j'ai trouvé un bug aussi célèbre.

Le journaliste a décrit exactement le test X: en curl avec le backend par défaut (OpenSSL), il ne s'exécutait pas, contrairement à curl avec le backend GnuTLS. Je n'étais pas trop paresseux, j'ai récupéré les boucles de la source avec l'option --with-gnutls et envoyé une longue demande. Et, enfin, un autre client, en plus du JDK, a renvoyé le statut HTTP 500 avec «l'exception de sécurité»!

J'ai écrit à ce sujet au client en réponse à l'argument «Eh bien, curl fonctionne» et j'ai reçu un nouveau certificat, régénéré et soigneusement installé sur le serveur. Avec cela, ma configuration pour WebClient a très bien fonctionné. Fin heureuse.

L'épopée SSL a pris plus de deux semaines avec toute la correspondance (elle comprenait l'étude de journaux Java détaillés, le choix du code d'un autre projet qui fonctionne pour le client, et juste le nez).

Ce qui m'a dérouté pendant longtemps, en plus de la différence de comportement du client, c'est que le serveur a été configuré de telle manière que le certificat a demandé, mais n'a pas vérifié. Cependant, il y a des explications à cela dans la spécification:
De plus, si un aspect de la chaîne de certificats était inacceptable (par exemple, il n'a pas été signé par une autorité de certification connue et fiable), le serveur PEUT, à sa discrétion, poursuivre la prise de contact (en considérant le client non authentifié) ou envoyer une alerte fatale.

Source: https://habr.com/ru/post/fr414909/


All Articles