Mutual authentication with TLS

Mutual or mTLS means that just like how a client will only connect to servers with valid certificates, the server will also verify the client certificate and only allow connections if the client key pair is accepted by the server. This is useful for example in microservices where only other known services are allowed to interact with a service, and public access should be denied.

For mTLS to work the server must be set up with a keystore containing the CA (certificate authority) public key used to sign the individual certs for clients that are allowed to access the server, just like how in a regular TLS/HTTPS scenario the client must be able to verify the server certificate.

Since the CA is what controls what clients can access a service, it is likely an organisation or service specific CA rather than a normal public one like what you use for a public web server.

Setting the server up

A JSK store can be prepared with the right contents, or created on the fly from cert files in some location the server can access for reading, in this sample we use cert files available on the classpath. The server is set up with its own private key and cert as well as a trust store with a CA to trust client certificates from:

Scala
sourcepackage example.myapp.helloworld

import akka.actor.ActorSystem
import akka.http.scaladsl.ConnectionContext
import akka.http.scaladsl.Http
import akka.http.scaladsl.HttpsConnectionContext
import akka.http.scaladsl.model.HttpRequest
import akka.http.scaladsl.model.HttpResponse
import akka.pki.pem.DERPrivateKeyLoader
import akka.pki.pem.PEMDecoder
import example.myapp.helloworld.grpc._
import org.slf4j.LoggerFactory

import java.security.KeyStore
import java.security.SecureRandom
import java.security.cert.Certificate
import java.security.cert.CertificateFactory
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import scala.concurrent.ExecutionContext
import scala.concurrent.Future
import scala.io.Source

object MtlsGreeterServer {
  def main(args: Array[String]): Unit = {
    val system = ActorSystem("MtlsHelloWorldServer")
    new MtlsGreeterServer(system).run()
    // ActorSystem threads will keep the app alive until `system.terminate()` is called
  }

}

class MtlsGreeterServer(system: ActorSystem) {

  private val log = LoggerFactory.getLogger(classOf[MtlsGreeterServer])
  def run(): Future[Http.ServerBinding] = {
    // Akka boot up code
    implicit val sys: ActorSystem = system
    implicit val ec: ExecutionContext = sys.dispatcher

    // Create service handlers
    val service: HttpRequest => Future[HttpResponse] =
      GreeterServiceHandler(new GreeterServiceImpl())

    // Bind service handler servers to localhost:8443
    val binding =
      Http().newServerAt("127.0.0.1", 8443).enableHttps(serverHttpContext).bind(service)

    // report successful binding
    binding.foreach { binding => log.info(s"gRPC server bound to: {}", binding.localAddress) }

    binding
  }

  private def serverHttpContext: HttpsConnectionContext = {
    val certFactory = CertificateFactory.getInstance("X.509")

    // keyStore/keymanagers are for the server cert and private key
    val keyStore = KeyStore.getInstance("PKCS12")
    keyStore.load(null)
    val serverCert = certFactory.generateCertificate(getClass.getResourceAsStream("/certs/localhost-server.crt"))
    val serverPrivateKey =
      DERPrivateKeyLoader.load(PEMDecoder.decode(classPathFileAsString("certs/localhost-server.key")))
    keyStore.setKeyEntry(
      "private",
      serverPrivateKey,
      // No password for our private key
      new Array[Char](0),
      Array[Certificate](serverCert))
    val keyManagerFactory = KeyManagerFactory.getInstance("SunX509")
    keyManagerFactory.init(keyStore, null)
    val keyManagers = keyManagerFactory.getKeyManagers

    // trustStore/trustManagers are for what client certs the server trust
    val trustStore = KeyStore.getInstance("PKCS12")
    trustStore.load(null)
    // any client cert signed by this CA is allowed to connect
    trustStore.setEntry(
      "rootCA",
      new KeyStore.TrustedCertificateEntry(
        certFactory.generateCertificate(getClass.getResourceAsStream("/certs/rootCA.crt"))),
      null)
    /*
    // or specific client cert (probably less useful)
    trustStore.setEntry(
      "client",
      new KeyStore.TrustedCertificateEntry(
        certFactory.generateCertificate(getClass.getResourceAsStream("/certs/client1.crt"))),
      null)
     */
    val tmf = TrustManagerFactory.getInstance("SunX509")
    tmf.init(trustStore)
    val trustManagers = tmf.getTrustManagers

    ConnectionContext.httpsServer { () =>
      val context = SSLContext.getInstance("TLS")
      context.init(keyManagers, trustManagers, new SecureRandom)

      val engine = context.createSSLEngine()
      engine.setUseClientMode(false)

      // require client certs
      engine.setNeedClientAuth(true)

      engine
    }
  }

  private def classPathFileAsString(path: String): String =
    Source.fromResource(path).mkString
}
Java
sourcepackage example.myapp.helloworld;

import akka.actor.ActorSystem;
import akka.http.javadsl.ConnectionContext;
import akka.http.javadsl.Http;
import akka.http.javadsl.HttpsConnectionContext;
import akka.http.javadsl.ServerBinding;
import akka.http.javadsl.model.AttributeKeys;
import akka.http.javadsl.model.HttpRequest;
import akka.http.javadsl.model.HttpResponse;
import akka.http.javadsl.model.SslSessionInfo;
import akka.japi.function.Function;
import akka.pki.pem.DERPrivateKeyLoader;
import akka.pki.pem.PEMDecoder;
import akka.stream.Materializer;
import akka.stream.SystemMaterializer;
import example.myapp.helloworld.grpc.GreeterService;
import example.myapp.helloworld.grpc.GreeterServiceHandlerFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.util.Arrays;
import java.util.concurrent.CompletionStage;
import java.util.stream.Collectors;

class MtlsGreeterServer {

  private static final Logger log = LoggerFactory.getLogger(MtlsGreeterServer.class);

  public static void main(String[] args) throws Exception {
    ActorSystem sys = ActorSystem.create("MtlsHelloWorldServer");

    run(sys).thenAccept(binding -> {
      log.info("gRPC server bound to {}", binding.localAddress());
    });

    // ActorSystem threads will keep the app alive until `system.terminate()` is called
  }

  public static CompletionStage<ServerBinding> run(ActorSystem sys) throws Exception {
    Materializer mat = SystemMaterializer.get(sys).materializer();

    // Instantiate implementation
    GreeterService impl = new GreeterServiceImpl(mat);

    Function<HttpRequest, CompletionStage<HttpResponse>> service =
      GreeterServiceHandlerFactory.create(impl, sys);

    return Http
      .get(sys)
      .newServerAt("127.0.0.1", 8443)
      .enableHttps(serverHttpContext())
      .bind(service);
  }

  private static HttpsConnectionContext serverHttpContext() {
    try {
      CertificateFactory certFactory = CertificateFactory.getInstance("X.509");

      // keyStore is for the server cert and private key
      KeyStore keyStore = KeyStore.getInstance("PKCS12");
      keyStore.load(null);
      PrivateKey serverPrivateKey =
        DERPrivateKeyLoader.load(PEMDecoder.decode(classPathFileAsString("/certs/localhost-server.key")));
      Certificate serverCert = certFactory.generateCertificate(
        MtlsGreeterServer.class.getResourceAsStream("/certs/localhost-server.crt"));
      keyStore.setKeyEntry(
        "private",
        serverPrivateKey,
        // No password for our private key
        new char[0],
        new Certificate[]{ serverCert });
      KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("SunX509");
      keyManagerFactory.init(keyStore, null);
      final KeyManager[] keyManagers = keyManagerFactory.getKeyManagers();

      // trustStore is for what client certs the server trust
      KeyStore trustStore = KeyStore.getInstance("PKCS12");
      trustStore.load(null);
      // any client cert signed by this CA is allowed to connect
      trustStore.setEntry(
        "rootCA",
        new KeyStore.TrustedCertificateEntry(
          certFactory.generateCertificate(MtlsGreeterServer.class.getResourceAsStream("/certs/rootCA.crt"))),
        null);
      /*
      // or specific client certs (less likely to be useful)
      trustStore.setEntry(
        "client1",
        new KeyStore.TrustedCertificateEntry(
          certFactory.generateCertificate(getClass().getResourceAsStream("/certs/localhost-client.crt"))),
        null)
       */
      TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509");
      tmf.init(trustStore);
      final TrustManager[] trustManagers = tmf.getTrustManagers();

      HttpsConnectionContext httpsContext = ConnectionContext.httpsServer(() -> {
        SSLContext context = SSLContext.getInstance("TLS");
        context.init(keyManagers, trustManagers, new SecureRandom());

        SSLEngine engine = context.createSSLEngine();
        engine.setUseClientMode(false);

        // require client certs
        engine.setNeedClientAuth(true);

        return engine;
      });
      return httpsContext;

    } catch (Exception ex) {
      throw new RuntimeException("Failed setting up the server HTTPS context", ex);
    }
  }

  private static String classPathFileAsString(String path) {
    try (InputStream inputStream = MtlsGreeterServer.class.getResourceAsStream(path)) {
      if (inputStream == null) throw new IllegalArgumentException("'" + path + "' is not present on the classpath");
      return new BufferedReader(
        new InputStreamReader(inputStream, StandardCharsets.UTF_8))
        .lines()
        .collect(Collectors.joining("\n"));
    } catch (Exception ex) {
      throw new RuntimeException("Failed reading server key from classpath", ex);
    }
  }

}

When run the server will only accept client connections that use a keypair that it considers valid, other connections will be denied and fail with a TLS protocol error.

Setting the client up

In the client, the trust store must be set up to trust the server cert, in our sample it is signed with the same CA as the server. The key store contains the public and private key for the client:

Scala
sourcepackage example.myapp.helloworld

import akka.actor.ActorSystem
import akka.grpc.GrpcClientSettings
import akka.pki.pem.{ DERPrivateKeyLoader, PEMDecoder }
import example.myapp.helloworld.grpc.GreeterServiceClient
import example.myapp.helloworld.grpc.HelloRequest

import java.security.{ KeyStore, SecureRandom }
import java.security.cert.{ Certificate, CertificateFactory }
import javax.net.ssl.{ KeyManagerFactory, SSLContext, TrustManagerFactory }
import scala.concurrent.ExecutionContext
import scala.io.Source
import scala.util.Success
import scala.util.Failure

object MtlsGreeterClient {

  def main(args: Array[String]): Unit = {
    implicit val sys: ActorSystem = ActorSystem.create("MtlsHelloWorldClient")
    implicit val ec: ExecutionContext = sys.dispatcher

    val clientSettings = GrpcClientSettings.connectToServiceAt("localhost", 8443).withSslContext(sslContext())

    val client = GreeterServiceClient(clientSettings)

    val reply = client.sayHello(HelloRequest("Jonas"))

    reply.onComplete { tryResponse =>
      tryResponse match {
        case Success(reply) =>
          println(s"Successful reply: $reply")
        case Failure(exception) =>
          println("Request failed")
          exception.printStackTrace()
      }
      sys.terminate()
    }
  }

  def sslContext(): SSLContext = {
    val clientPrivateKey =
      DERPrivateKeyLoader.load(PEMDecoder.decode(classPathFileAsString("certs/client1.key")))
    val certFactory = CertificateFactory.getInstance("X.509")

    // keyStore is for the client cert and private key
    val keyStore = KeyStore.getInstance("PKCS12")
    keyStore.load(null)
    keyStore.setKeyEntry(
      "private",
      clientPrivateKey,
      // No password for our private client key
      new Array[Char](0),
      Array[Certificate](certFactory.generateCertificate(getClass.getResourceAsStream("/certs/client1.crt"))))
    val keyManagerFactory = KeyManagerFactory.getInstance("SunX509")
    keyManagerFactory.init(keyStore, null)
    val keyManagers = keyManagerFactory.getKeyManagers

    // trustStore is for what server certs the client trust
    val trustStore = KeyStore.getInstance("PKCS12")
    trustStore.load(null)
    // accept any server cert signed by this CA
    trustStore.setEntry(
      "rootCA",
      new KeyStore.TrustedCertificateEntry(
        certFactory.generateCertificate(getClass.getResourceAsStream("/certs/rootCA.crt"))),
      null)
    val tmf = TrustManagerFactory.getInstance("SunX509")
    tmf.init(trustStore)
    val trustManagers = tmf.getTrustManagers

    val context = SSLContext.getInstance("TLS")
    context.init(keyManagers, trustManagers, new SecureRandom())
    context
  }

  private def classPathFileAsString(path: String): String =
    Source.fromResource(path).mkString

}
Java
sourcepackage example.myapp.helloworld;

import akka.actor.ActorSystem;
import akka.grpc.GrpcClientSettings;
import akka.pki.pem.DERPrivateKeyLoader;
import akka.pki.pem.PEMDecoder;
import example.myapp.helloworld.grpc.GreeterServiceClient;
import example.myapp.helloworld.grpc.HelloReply;
import example.myapp.helloworld.grpc.HelloRequest;

import javax.net.ssl.*;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.SecureRandom;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
import java.util.concurrent.CompletionStage;
import java.util.stream.Collectors;

public class MtlsGreeterClient {

  public static void main(String[] args) {
    ActorSystem system = ActorSystem.create("MtlsHelloWorldClient");

    GrpcClientSettings clientSettings =
      GrpcClientSettings.connectToServiceAt("localhost", 8443, system)
        .withSslContext(sslContext());

    GreeterServiceClient client = GreeterServiceClient.create(clientSettings, system);

    CompletionStage<HelloReply> reply = client.sayHello(HelloRequest.newBuilder().setName("Jonas").build());

    reply.whenComplete((response, error) -> {
      if (error == null) {
        System.out.println("Successful reply: " + reply);
      } else {
        System.out.println("Request failed");
        error.printStackTrace();
      }
      system.terminate();
    });
  }

  private static SSLContext sslContext() {
    try {
      PrivateKey clientPrivateKey =
        DERPrivateKeyLoader.load(PEMDecoder.decode(classPathFileAsString("/certs/client1.key")));
      CertificateFactory certFactory = CertificateFactory.getInstance("X.509");

      // keyStore is for the client cert and private key
      KeyStore keyStore = KeyStore.getInstance("PKCS12");
      keyStore.load(null);
      Certificate clientCertificate = certFactory.generateCertificate(MtlsGreeterClient.class.getResourceAsStream("/certs/client1.crt"));
      keyStore.setKeyEntry(
        "private",
        clientPrivateKey,
        // No password for our private client key
        new char[0],
        new Certificate[]{clientCertificate});
      KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("SunX509");
      keyManagerFactory.init(keyStore, null);
      KeyManager[] keyManagers = keyManagerFactory.getKeyManagers();

      // trustStore is for what server certs the client trust
      KeyStore trustStore = KeyStore.getInstance("PKCS12");
      trustStore.load(null);
      // accept any server cert signed by this CA
      trustStore.setEntry(
        "rootCA",
        new KeyStore.TrustedCertificateEntry(
          certFactory.generateCertificate(MtlsGreeterClient.class.getResourceAsStream("/certs/rootCA.crt"))),
        null);
      TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509");
      tmf.init(trustStore);
      TrustManager[] trustManagers = tmf.getTrustManagers();

      SSLContext context = SSLContext.getInstance("TLS");
      context.init(keyManagers, trustManagers, new SecureRandom());
      return context;
    } catch (Exception ex) {
      throw new RuntimeException("Failed to set up SSL context for the client", ex);
    }
  }

  private static String classPathFileAsString(String path) {
    try (InputStream inputStream = MtlsGreeterServer.class.getResourceAsStream(path)) {
      if (inputStream == null) throw new IllegalArgumentException("'" + path + "' is not present on the classpath");
      return new BufferedReader(
        new InputStreamReader(inputStream, StandardCharsets.UTF_8))
        .lines()
        .collect(Collectors.joining("\n"));
    } catch (Exception ex) {
      throw new RuntimeException("Failed reading server key from classpath", ex);
    }
  }
}

A client presenting a keypair will be able to connect to both servers requiring regular HTTPS gRPC services and mTLS servers that accept the client certificate.

Found an error in this documentation? The source code for this page can be found here. Please feel free to edit and contribute a pull request.