HTTPS Client Certificate Authentication With Java

kmack

k-mack

Posted on March 7, 2021

HTTPS Client Certificate Authentication With Java

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,
Enter fullscreen mode Exit fullscreen mode

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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Add the CA certificate chain to the JVM's cacerts.
  2. 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,

  1. find and export the trusted root CA as a DER encoded binary X.509 (CER), and
  2. 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
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
kmack
k-mack

Posted on March 7, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related