Java mTLS Http Client

I’m working on a project that integrates with 3rd party system, and we are using mTLS (mutual TLS) for communication. The issues I’m having are:

  • to make HttpClient work with the certificates they provided us with;
  • to make HttpClient work from localhost with ssh tunnel to our server.

I’m using Java 11, and native HttpClient. There should be no dependency required, but of course, you can add some framework for dependency injection, and do some checks (for example path or password must not be empty), but I’ve skipped that for the sake of simplicity. The code should be self-describing, so I’m not going to explain too much of it. Also, we’ll be using .jks - Java KeyStore.

Big thanks to Igor Kundović, a senior colleague of mine, who helped me debug and solve the issue! He wrote the code in the examples of this post.

Creating SSLContext and HttpClient

We’ll start by implementing an SSLContext which HttpClient will use.

import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;

import java.io.FileInputStream;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStore;

public class SSLContextFactory {

    public SSLContext createAndGetSSLContext(String keyStore, String trustStore, String keyStorePassword,
            String trustStorePassword) throws IOException, GeneralSecurityException {

        final KeyManager[] keyManagers = getKeyManagers(keyStore, keyStorePassword);
        final TrustManager[] trustManagers = getTrustManagers(trustStore, trustStorePassword);
        final SSLContext sslContext = SSLContext.getInstance("SSL");
        
        sslContext.init(keyManagers, trustManagers, null);

        return sslContext;
    }

    private KeyManager[] getKeyManagers(String keyStore, String keyStorePassword) throws IOException,
            GeneralSecurityException {

        String alg = KeyManagerFactory.getDefaultAlgorithm();
        KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(alg);

        FileInputStream fis = new FileInputStream(keyStore);
        KeyStore ks = KeyStore.getInstance("jks");
        ks.load(fis, keyStorePassword.toCharArray());
        fis.close();

        keyManagerFactory.init(ks, keyStorePassword.toCharArray());

        return keyManagerFactory.getKeyManagers();
    }

    private KeyManager[] getTrustManagers(String keyStore, String keyStorePassword) throws IOException, 
            GeneralSecurityException {

        String alg = TrustManagerFactory.getDefaultAlgorithm();
        TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(alg);

        FileInputStream fis = new FileInputStream(trustStore);
        KeyStore ks = KeyStore.getInstance("jks");
        ks.load(fis, trustStorePassword.toCharArray());
        fis.close();

        trustManagerFactory.init(ks);

        return trustManagerFactory.getTrustManagers();
    }
}

Next, when we create client, we can initialize it with SSL context.

public class Main {
    
    public static void main(String args[]) {
        final String keyStorePath = "/full/path/name/keyStore.jks";
        final String keyStorePassword = "password";
        final String trustStorePath = "/full/path/name/trustStore.jks";
        final String trustStorePassword = "password";
        
        final SSLContextFactory sslContextFactory = new SSLContextFactory();
        final SSLContext sslContext = SSLContextFactory.createAndGetSSLContext(keyStorePath, trustStorePath, 
            keyStorePassword, trustStorePassword);

        final HttpClient httpClient = HttpClient.newBuilder()
                .sslContext(ctx)
                .connectTimeout(Duration.ofSeconds(10))
                .build();

        // now make http request and send it...
    }
}

This will work from the server with domain name. But if you try from localhost, you will get an error for loading trust store, since it doesn’t recognize localhost, just the full domain name.

Making it work from localhost

To get over that, you can trick the trust store so it doesn’t check for validity of domain name. We’ll make our custom trust manager:

import javax.net.ssl.SSLEngine;
import javax.net.ssl.X509ExtendedTrustManager;
import javax.net.ssl.X509TrustManager;

import java.net.Socket;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;

public class CustomTrustManagerImpl extends X509ExtendedTrustManager {

    private final X509TrustManager delegate;

    CustomTrustManagerImpl(X509TrustManager delegate) {
        this.delegate = (X509TrustManager) ObjectUtil.checkNotNull(delegate, "delegate");
    }

    public void checkClientTrusted(X509Certificate[] chain, String s) throws CertificateException {
        this.delegate.checkClientTrusted(chain, s);
    }

    public void checkClientTrusted(X509Certificate[] chain, String s, Socket socket) throws CertificateException {
        this.delegate.checkClientTrusted(chain, s);
    }

    public void checkClientTrusted(X509Certificate[] chain, String s, SSLEngine sslEngine) throws CertificateException {
        this.delegate.checkClientTrusted(chain, s);
    }

    public void checkServerTrusted(X509Certificate[] chain, String s) throws CertificateException {
    }

    public void checkServerTrusted(X509Certificate[] chain, String s, Socket socket) throws CertificateException {
    }

    public void checkServerTrusted(X509Certificate[] chain, String s, SSLEngine sslEngine) throws CertificateException {
    }

    public X509Certificate[] getAcceptedIssuers() {
        return this.delegate.getAcceptedIssuers();
    }
}

This class is just a copy of io.netty.handler.ssl.util.X509TrustManagerWrapper, but you don’t need to have the dependency on that project. It’s just an example that works for us. The only important thing to change here is checkServerTrusted methods. They should do nothing.

We’ll create a new implementation of our SSLContextFactory, it just wraps our existing trust managers in CustomTrustManagerImpl:

public class CustomSSLContextFactory extends SSLContextFactory {

    @Override
    protected TrustManager[] getTrustManagers(final String trustStore, final String trustStorePassword) throws
            IOException, GeneralSecurityException {
        final TrustManager[] trustManagers = super.getTrustManagers(trustStore, trustStorePassword);
        final var customTrustManager = new CustomTrustManagerImpl((X509TrustManager) trustManagers[0]);
        return new TrustManager[]{customTrustManager};
    }
}

CustomSSLContextFactory won’t check for anything, it will just use the trust store as is, and it will work from a local machine. We can open an ssh tunnel like so:

ssh -f user@my.server -L 1443:3rd.party.server:443 -N

After replacing the implementation of SSLContextFactory to CustomSSLContextFactory in Main class, we can make requests to 3rd party server acting like we’re sending the request from our servers domain. Making calls to their server will require us to use localhost:1443, instead of the full domain name. Only drawback we’re having right now is that we cannot receive requests from them, but we can make it work with port forwarding.