Hi, Habr!
Recently, I had to screw SSL with two-way authentication (mutual authentication) to the Spring Reactive Webclient. It would seem that the matter is simple, but it turned into a wandering in the JDK source code with an unexpected ending. Experience accumulated on the whole article, which may be useful to engineers in everyday tasks or in preparation for the interview.
Formulation of the problem
There is a client-side REST service that works via HTTPS.
You need to access it from a client-side Java application.The first thing I was given in this quest is 2 files with the .pem extension - a client certificate and a private key. I checked their operation with the help of Postman: I pointed out the paths to them in the settings and, after I had a request, made sure that the server responds with 200 OK and a sensible response body. Separately, I checked that without a client certificate, the server returns HTTP status 500 and a short message in the response body that a “Security exception” occurred with a specific code.
The next step was to properly configure the client Java application.
For REST requests, I used the Spring Reactive WebClient with non-blocking I / O.
In the documentation there is
an example of how to customize it by passing it an SslContext object, which just stores certificates and private keys.
My configuration in a simplified version was almost copy-paste from the documentation:
SslContext sslContext = SslContextBuilder .forClient() .keyManager(…) .build(); ClientHttpConnector connector = new ReactorClientHttpConnector( builder -> builder.sslContext(sslContext)); WebClient webClient = WebClient.builder() .clientConnector(connector).build();
Adhering to the TDD principle, I also wrote a test that uses WebTestClient instead of WebClient, which displays a bunch of debugging information. The very first assertion was this:
webTestClient .post() .uri([ ]) .body(BodyInserters.fromObject([ , , ])) .exchange() .expectStatus().isOk()
This simple test did not immediately pass: the server returned 500 with the same body as in the case if you did not specify the client certificate in Postman.
Separately, I note that at the time of debugging, I turned on the “do not check server certificate” option, namely, I passed the InsecureTrustManagerFactory instance as a TrustManager for SslContext. This measure was redundant, but excluded half the options for sure.
The debug information in the test did not shed light on the problem, but everything looked like something went wrong at the SSL handshake stage, so I decided to compare in more detail how the connection happens in both cases: for Postman and for the Java client. All this can be viewed using Wireshark - it is such a popular network traffic analyzer. At the same time I saw how SSL handshake happens with two-way authentication, so to speak, live (this is what people like to ask at interviews):
- First and foremost, the client sends a Client Hello message containing meta information like the protocol version and the list of encryption algorithms it supports
- In response, the server immediately sends a packet of the following messages: Server Hello, Certificate, Server Key Exchange, Certificate Request, Server Hello Done .
Server Hello indicates the encryption algorithm selected by the server (cipher suite). Inside the Certificate is a server certificate. Server Key Exchange carries some information necessary for encryption, depending on the chosen algorithm (we are not interested in the details now, so we will assume that this is just a public key, although this is incorrect!). Also, in the case of two-way authentication, in a Certificate Request message, the server makes a client certificate request and explains which formats it supports and which issuers it trusts. - After receiving this information, the client verifies the server certificate and sends its certificate, its “public key”, and other information in the following messages: Certificate, Client Key Exchange, Certificate Verify . The last is the ChangeCipherSpec message, which indicates that all further communication will be encrypted
- Finally, after all these scrappers, the server will verify the client certificate and, if everything is in order, then it gives an answer.
After fifteen minutes of sticking to the traffic, I noticed that the Java client, in response to the
Certificate Request from the server, for some reason does not send its certificate, unlike the Postman client. That is, there is a Certificate message, but it is empty.
Next I would need to look first at
the TLS protocol specification , which literally says the following:
This is a list of CAs.
This is a list of certificate_authorities, specified in the
Certificate Request message, coming from the server. The client certificate (at least one of the chain) must be signed by one of the issuers listed in this list. Call it a
check X.I did not know about this condition and discovered it when I reached the depths of the JDK in debugging (I have this JDK9). Netty HttpClient, which is the basis of Spring WebClient, uses the default SslEngine from the JDK. Alternatively, you can switch it to the OpenSSL provider by adding the necessary dependencies, but I ultimately did not need this.
So, I set breakpoints inside the sun.security.ssl.ClientHandshaker class and in the handler for the serverHelloDone message, an X test was found that did not pass: none of the issuers in the client certificate chain were in the list of issuers that the server trusts ( from
Certificate Request message from server).
I turned to the customer for a new certificate, but the customer objected that everything was working fine for him, and handed Python a script with which he usually checked the validity of certificates. The script did nothing superfluous, except for sending an HTTPS request using the Requests library, and returned 200 OK. Finally, I was surprised when the good old curl also returned 200 OK. Immediately I remembered the anecdote: "The whole company is not keeping pace, one lieutenant keeps pace."
Curl is, of course, a reputable utility, but the TLS standard is also not a piece of toilet paper. Not knowing what else to check, I wandered aimlessly through the documentation for curl, and on Github, where I discovered
such a known bug.
The reporter described X exactly to the test: in curl with the default backend (OpenSSL) it was not performed, unlike curl with the GnuTLS backend. I was not lazy, I gathered curl from sources with the option - with
-gnutls , and sent a long-suffering request. And, finally, another client, besides the JDK, returned HTTP status 500 with “Secutiry exception”!
I wrote about this to the customer in response to the argument “Well, curl works the same” and received a new certificate, newly generated and neatly installed on the server. With it, my WebClient configuration worked fine. Happy End.
The epic with setting up SSL took more than two weeks with all the correspondences (it also included the study of detailed Java logs, picking the code of another project that works for the customer, and simply picking their nose).
What confused me for a long time, besides the difference in customer behavior, was that the server was configured in such a way that the certificate was requested, but not verified. However, there are explanations for this in the speculation:
It was not a problem, for example, it should be noted that it was a certificate of choice or a certificate.