Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: mTLS server and client setup and docs #1781

Merged
merged 12 commits into from
May 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/main/paradox/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* [Binary Compatibility](binary-compatibility.md)
* [gRPC API Design](apidesign.md)
* [Deployment](deploy.md)
* [mTLS](mtls.md)
* [Troubleshooting](troubleshooting.md)

@@@
43 changes: 43 additions & 0 deletions docs/src/main/paradox/mtls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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
: @@snip [MtlsGreeterServer.scala](/plugin-tester-scala/src/main/scala/example/myapp/helloworld/MtlsGreeterServer.scala) { #full-server }

Java
: @@snip [MtlsGreeterServer.java](/plugin-tester-java/src/main/java/example/myapp/helloworld/MtlsGreeterServer.java) { #full-server }

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
: @@snip [MtlsGreeterClient.scala](/plugin-tester-scala/src/main/scala/example/myapp/helloworld/MtlsGreeterClient.scala) { #full-client }

Java
: @@snip [MtlsGreeterClient.java](/plugin-tester-java/src/main/java/example/myapp/helloworld/MtlsGreeterClient.java) { #full-client }

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.
4 changes: 3 additions & 1 deletion plugin-tester-java/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ repositories {

def scalaVersion = org.gradle.util.VersionNumber.parse(System.getenv("TRAVIS_SCALA_VERSION") ?: "2.12")
def scalaBinaryVersion = "${scalaVersion.major}.${scalaVersion.minor}"
def akkaVersion = "2.7.0"

dependencies {
implementation group: 'ch.megard', name: "akka-http-cors_${scalaBinaryVersion}", version: '1.1.3'
testImplementation "com.typesafe.akka:akka-stream-testkit_${scalaBinaryVersion}:2.7.0"
testImplementation "com.typesafe.akka:akka-stream-testkit_${scalaBinaryVersion}:${akkaVersion}"
implementation "com.typesafe.akka:akka-pki_${scalaBinaryVersion}:${akkaVersion}"
testImplementation "org.scalatest:scalatest_${scalaBinaryVersion}:3.2.12"
testImplementation "org.scalatestplus:junit-4-12_${scalaBinaryVersion}:3.2.2.0"
}
6 changes: 6 additions & 0 deletions plugin-tester-java/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<maven-dependency-plugin.version>3.1.2</maven-dependency-plugin.version>
<maven-exec-plugin.version>3.0.0</maven-exec-plugin.version>
<akka.http.cors.version>1.1.0</akka.http.cors.version>
<akka.version>2.7.0</akka.version>
<grpc.version>1.54.1</grpc.version> <!-- checked synced by VersionSyncCheckPlugin -->
<project.encoding>UTF-8</project.encoding>
<build-helper-maven-plugin>3.3.0</build-helper-maven-plugin>
Expand All @@ -29,6 +30,11 @@
<artifactId>akka-grpc-runtime_2.12</artifactId>
<version>${akka.grpc.project.version}</version>
</dependency>
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-pki_2.12</artifactId>
<version>${akka.version}</version>
</dependency>
<dependency>
<groupId>ch.megard</groupId>
<artifactId>akka-http-cors_2.12</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
*/

//#full-client
package 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);
}
}
}
//#full-client
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/*
* Copyright (C) 2018-2023 Lightbend Inc. <https://www.lightbend.com>
*/

//#full-server
package 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);
}
}

}
//#full-server
4 changes: 3 additions & 1 deletion plugin-tester-java/src/main/resources/application.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
akka.loglevel = INFO
akka.http.server.enable-http2 = true
akka.grpc.client {
"helloworld.GreeterService" {
host = 127.0.0.1
port = 8090
port = 8080
override-authority = foo.test.google.fr
use-tls = false
}
}
Loading