I recently had to develop a Java client to interface with an internal service over HTTPS that required client certificate authentication. It is not often that I need to dive into SSL certificates, and doing so usually requires me to step back and relearn some things. This situation was no different, but in an attempt to burn this stuff into my brain, I am writing about it here.
It's a Trust Thing
First thing's first: the client needs to trust the HTTPS connection that the service wants to establish. The server certificate used by the service is signed by an internal certificate authority (CA). Since the client code runs on the Java Virtual Machine (JVM), it is by default subject to the collection of trusted CA certificate chains (Chain of Trust) used the JVM, which -- and rightly so -- does not include the CA that signed the service's server certificate.
The JVM's Chain of Trust is contained inside the file cacerts
located in the Java Development Kit's (JDK) security directory. You can use keytool
, which comes with the Java Development Kit (JDK), if you are curious about which CAs are trusted by your JVM. In fact, keytool
has the command-line option -cacerts
to short-circuit specifying the JDK's cacerts
as the command's keystore. For example, if you wanted to know if your JDK trusts certificates generated by Let's Encrypt, you would want to know that it trusts IdenTrust’s "DST Root CA X3":
PS C:\Program Files\Java\zulu11.41.23-jdk11.0.8> .\bin\keytool.exe -list -cacerts | Select-String "IdenTrust"
Enter keystore password:
identrustcommercial [jdk], Jan 16, 2014, trustedCertEntry,
identrustdstx3 [jdk], Sep 30, 2000, trustedCertEntry, # <-- Bingo!
identrustpublicca [jdk], Jan 16, 2014, trustedCertEntry,
Client code using the default JVM Chain of Trust will not allow a secure connection to be established with the service. The service's server certificate, inspected by the client's JVM during the SSL/TLS handshake, is seen as untrustworthy since it is signed by a CA not found in cacerts
. This handshaking and rejection of trust is taken care of by the JVM networking and security code running beneath the client code.
As a quick demonstration, the following (Spock) test asserts that the client JVM code fails to create an SSL connection with the service. Note that I chose to use Vert.x Web Client to handle interacting with the service, but don't let this decision distract from the core content of this post. Nevertheless, if you haven't used Vert.x, I encourage you to try it out -- especially for building server-side network applications.
class ClientTest extends Specification {
Vertx vertx
WebClientOptions webClientOptions
WebClient webClient
AsyncConditions conditions
def setup() {
vertx = Vertx.vertx()
webClientOptions = new WebClientOptions().setSsl(true)
conditions = new AsyncConditions(1)
}
def "cannot establish secure connection with an untrusted CA"() {
given:
webClient = WebClient.create(vertx, webClientOptions)
expect:
webClient.get(443, "service.cn.local", "/api/endpoint").send({ event ->
conditions.evaluate {
assert event.failed()
assert SSLHandshakeException.class.isInstance(event.cause())
assert "Failed to create SSL connection" == event.cause().message
}
})
}
def cleanup() {
webClient?.close()
vertx.close()
}
}
The solution is straight-forward: we need to tell the JVM that it can trust the service's server certificate.
Trust Me, I Got This
There are a couple of ways to have the JVM trust that the connection attempted to be made by the service really is secure:
- Add the CA certificate chain to the JVM's
cacerts
. - Create a separate keystore and use that in the client code.
Although the first option provides a quick solution to the problem, it will effectively allow any future code that runs on the JVM with the modified cacerts
to trust certificates signed by the internal CA. This may be perfectly acceptable given the CA;
e.g., if it's the root CA of your organization, then it probably makes sense to include it in your JVM's default Chain of Trust. In my case, the internal CA is for a dedicated and isolated system,
and I actually do not want to create an environment where any Java application running on the JVM can interface with the internal service. I only want the one application that I am developing to have this capability. Additionally, the client code does not need to interface with any other system over a secure connection, so I do not need to trust any SSL certificates besides those that are signed by the internal CA. Therefore, the second option suits me better.
Create a Java Keystore
The good people at IT sent me the internal CA certificate chain and a client certificate for authenticating with the service. The CA certificate chain was a PFX file and the client certificate was a P12 file. I installed both on my Windows machine to enable my web browsers to trust and authenticate with the internal service.
From this setup, a separate Java keystore containing the internal CA certificate chain can be created. First, export the root CA certificate and its intermediate certificates. These can be found on a Windows machine at Control Panel > Internet Options > Content (Tab) > Certificates (Button)
. From there,
- find and export the trusted root CA as a DER encoded binary X.509 (CER), and
- find and export each intermediate certificates as a DER encoded binary X.509 (CER).
Use keytool
to create a new keystore file that trusts the exported certificates (add the root CA first):
PS C:\Program Files\Java\zulu11.41.23-jdk11.0.8> .\bin\keytool.exe -importcert -alias internal-ca-root -file C:\Temp\internal-ca-root.cer --storetype JKS -keystore C:\Temp\internal.jks
Enter keystore password:
Re-enter new password:
Trust this certificate? [no]: yes
Certificate was added to keystore
PS C:\Program Files\Java\zulu11.41.23-jdk11.0.8> .\bin\keytool.exe -importcert -alias internal-ca-intermediate -file C:\Temp\internal-ca-intermediate.cer --storetype JKS -keystore C:\Temp\internal.jks
Enter keystore password:
Re-enter new password:
Trust this certificate? [no]: yes
Certificate was added to keystore
Because the first command creates the keystore, the password you enter when prompted will set the password for it. When prompted for the password after entering the second command, the same password must be used.
Configure Client to Use Keystore
The created Java keystore containing the Chain of Trust can now be used in the client code:
class ClientTest extends Specification {
// ...
def "can establish secure connection with separate keystore"() {
given:
webClientOptions
.setTrustStoreOptions(new JksOptions()
.setPath(CUSTOM_KEYSTORE_PATH)
.setPassword(CUSTOM_KEYSTORE_PASSWORD))
webClient = WebClient.create(vertx, webClientOptions)
when:
webClient.get(443, "service.cn.local", "/api/endpoint").send({ event ->
conditions.evaluate {
assert event.succeeded()
assert CONNECTED_BUT_NOT_AUTHENTICATED == event.result().bodyAsString()
}
})
then:
conditions.await(5)
}
}
Although a secure connection is now established between the client and service, the two-way SSL authentication is not yet satisfied. Hence, the service responds with a message indicating the client cannot reach the desired endpoint because it was not authenticated.
Certificate, Please
As mentioned at the beginning of the post
-- and the original motivation for writing this --
the internal web service uses two-way SSL authentication. This means a client is authenticated when it presents a client certificate to the service that is issued by the root CA. If a client certificate is not provided (like in the test above) or it is not signed by the root CA, then the service denies the client's request.
The final step, therefore, is to configure the client code to authenticate with the server using the client certificate:
class ClientTest extends Specification {
// ...
def "can establish secure connection and authenticate with client certificate"() {
given:
webClientOptions
.setTrustStoreOptions(new JksOptions()
.setPath(CUSTOM_KEYSTORE_PATH)
.setPassword(CUSTOM_KEYSTORE_PASSWORD))
.setPfxKeyCertOptions(new PfxOptions()
.setPath(CLIENT_CERT_PATH)
.setPassword(CLIENT_CERT_PASSWORD))
webClient = WebClient.create(vertx, webClientOptions)
when:
webClient.get(443, "service.cn.local", "/api/endpoint").send({ event ->
conditions.evaluate {
assert event.succeeded()
assert CONNECTED_AND_AUTHENTICATED == event.result().bodyAsString()
}
})
then:
conditions.await(5)
}
}
That's it. The client-server connection is secured over HTTPS, and the service authenticates the client. Note that Vert.x Web Client makes this easy, but I assume it is just as easy with other HTTP client libraries that support HTTPS.
Conclusion
Sometimes you need to establish secure connections with servers that have not been signed by a globally trusted entity. In these situations, it is can be desirable to create a custom keystore containing the Chain of Trust associated with the server(s) you need to interface with. Java's keytool
can be used to do this.
If a server employs two-way SSL authentication, you can use an HTTP library, like Vert.x Web Client, to add a client certificate to your HTTPS connection.
Fin.
Top comments (3)
Great article. New to this area and it can be confusing and your article helps me a lot. Just curious is do your IT dept also give u a private key? stackoverflow.com/questions/111990... seems to indicate a private key is needed. Thanks for all info.
Thank you, and I'm glad it helped. You're correct: the P12 file that IT gave me is a PKCS #12 file, which contains my private key.
Thanks for the answer and the great article.