diff --git a/plugins/grpc-transport-auth/build.gradle b/plugins/grpc-transport-auth/build.gradle index 556431ff..323f8a57 100644 --- a/plugins/grpc-transport-auth/build.gradle +++ b/plugins/grpc-transport-auth/build.gradle @@ -33,10 +33,15 @@ dependencies { compile 'com.auth0:java-jwt:3.9.0' compile 'com.avast.grpc.jwt:grpc-java-jwt:0.2.0' + // see https://github.com/grpc/grpc-java/blob/master/SECURITY.md#netty for the compatibility version + runtimeOnly 'io.netty:netty-tcnative-boringssl-static:2.0.25.Final' + testRuntimeOnly 'io.netty:netty-tcnative-boringssl-static:2.0.25.Final' + testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' testCompile project(":tck") testCompile project(":client") testCompile 'org.springframework.boot:spring-boot-starter-test' + testCompile 'org.bouncycastle:bcpkix-jdk15on:1.66' testRuntime project(":plugins:grpc-transport") } diff --git a/plugins/grpc-transport-auth/src/main/java/com/github/bsideup/liiklus/transport/grpc/config/GRPCTLSConfiguration.java b/plugins/grpc-transport-auth/src/main/java/com/github/bsideup/liiklus/transport/grpc/config/GRPCTLSConfiguration.java new file mode 100644 index 00000000..b7f8e212 --- /dev/null +++ b/plugins/grpc-transport-auth/src/main/java/com/github/bsideup/liiklus/transport/grpc/config/GRPCTLSConfiguration.java @@ -0,0 +1,102 @@ +package com.github.bsideup.liiklus.transport.grpc.config; + +import com.github.bsideup.liiklus.transport.grpc.GRPCLiiklusTransportConfigurer; +import com.github.bsideup.liiklus.util.PropertiesUtil; +import com.google.auto.service.AutoService; +import io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.NettyServerBuilder; +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import lombok.Data; +import lombok.SneakyThrows; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.io.Resource; + +import java.io.File; + +@Slf4j +@AutoService(ApplicationContextInitializer.class) +public class GRPCTLSConfiguration implements ApplicationContextInitializer { + + @Override + public void initialize(GenericApplicationContext applicationContext) { + var environment = applicationContext.getEnvironment(); + + var tlsProperties = PropertiesUtil.bind(environment, new GRPCTLSProperties()); + + if (tlsProperties.getKey() == null) { + return; + } + + log.info("GRPC {}TLS ENABLED", tlsProperties.getTrustCert() != null ? "mutual " : ""); + + applicationContext.registerBean( + TLSGRPCTransportConfigurer.class, + () -> new TLSGRPCTransportConfigurer(tlsProperties) + ); + } + + @Value + static class TLSGRPCTransportConfigurer implements GRPCLiiklusTransportConfigurer { + + GRPCTLSProperties properties; + + @Override + public void apply(NettyServerBuilder builder) { + SslContext ctx = createSSLContext( + properties.getKey(), + properties.getKeyPassword(), + properties.getKeyCertChain(), + properties.getTrustCert() + ); + + builder.sslContext(ctx); + } + + /** + * Mostly copy of the https://github.com/grpc/grpc-java/tree/master/examples/example-tls + * and https://github.com/grpc/grpc-java/blob/master/SECURITY.md + * + * Refer to {@link io.netty.handler.ssl.SslContextBuilder#forServer(File keyCertChainFile, File keyFile, String keyPassword)} + * for more details. + * + * @param key a PKCS#8 private key file in PEM format + * @param keyPassword the password of the key or null if not protected + * @param keyCertChain an X.509 certificate chain file in PEM format + * @param trustCert file should contain an X.509 certificate collection in PEM format + * @return ready-to-use ssl context. + */ + @SneakyThrows + SslContext createSSLContext(Resource key, String keyPassword, Resource keyCertChain, Resource trustCert) { + SslContextBuilder sslClientContextBuilder = SslContextBuilder.forServer( + keyCertChain.getInputStream(), + key.getInputStream(), + keyPassword + ); + if (trustCert != null) { + sslClientContextBuilder.trustManager(trustCert.getInputStream()); + sslClientContextBuilder.clientAuth(ClientAuth.REQUIRE); + } + return GrpcSslContexts.configure(sslClientContextBuilder).build(); + } + } + + @ConfigurationProperties("grpc.tls") + @Data + static class GRPCTLSProperties { + + Resource key; + + String keyPassword; + + Resource keyCertChain; + + Resource trustCert; + + } +} \ No newline at end of file diff --git a/plugins/grpc-transport-auth/src/test/java/com/github/bsideup/liiklus/transport/grpc/GRPCTLSTest.java b/plugins/grpc-transport-auth/src/test/java/com/github/bsideup/liiklus/transport/grpc/GRPCTLSTest.java new file mode 100644 index 00000000..2e6d761b --- /dev/null +++ b/plugins/grpc-transport-auth/src/test/java/com/github/bsideup/liiklus/transport/grpc/GRPCTLSTest.java @@ -0,0 +1,294 @@ +package com.github.bsideup.liiklus.transport.grpc; + +import com.github.bsideup.liiklus.ApplicationRunner; +import com.github.bsideup.liiklus.GRPCLiiklusClient; +import com.github.bsideup.liiklus.protocol.LiiklusEvent; +import com.github.bsideup.liiklus.protocol.PublishRequest; +import io.grpc.ManagedChannel; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.NettyChannelBuilder; +import lombok.SneakyThrows; +import lombok.Value; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.junit.Test; +import org.junit.jupiter.api.function.ThrowingConsumer; +import org.springframework.util.ResourceUtils; + +import java.io.File; +import java.io.FileWriter; +import java.math.BigInteger; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static com.github.bsideup.liiklus.transport.grpc.GRPCAuthTest.getGRPCPort; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; + +public class GRPCTLSTest { + + @Test + public void shouldConnectWithTLS() { + GeneratedCert rootCA = createCertificate("ca", null, true); + GeneratedCert server = createCertificate("localhost", rootCA, false); + + withApp( + app -> { + app + .withProperty("grpc.tls.key", server.getPrivateKeyFile().toURI().toString()) + .withProperty("grpc.tls.keyCertChain", server.getCertificateFile().toURI().toString()); + }, + port -> { + var sslContext = GrpcSslContexts.forClient() + .trustManager(ResourceUtils.getFile(rootCA.getCertificateFile().toURI())) + .build(); + + var channel = NettyChannelBuilder + .forAddress("localhost", port) + .sslContext(sslContext) + .build(); + + publishWith(channel); + } + ); + } + + @Test + public void shouldFailOnPlaintext() { + GeneratedCert rootCA = createCertificate("ca", null, true); + GeneratedCert server = createCertificate("localhost", rootCA, false); + withApp( + app -> { + app + .withProperty("grpc.tls.key", server.getPrivateKeyFile().toURI().toString()) + .withProperty("grpc.tls.keyCertChain", server.getCertificateFile().toURI().toString()); + }, + port -> { + var channel = NettyChannelBuilder + .forAddress("localhost", port) + .usePlaintext() + .build(); + + assertThatThrownBy(() -> publishWith(channel)) + .asInstanceOf(type(StatusRuntimeException.class)) + .satisfies(Throwable::printStackTrace) + .returns(Status.Code.UNAVAILABLE, it -> it.getStatus().getCode()); + } + ); + } + + @Test + public void shouldFailOnWrongCA() { + GeneratedCert rootCA = createCertificate("ca", null, true); + GeneratedCert server = createCertificate("localhost", rootCA, false); + withApp( + app -> { + app + .withProperty("grpc.tls.key", server.getPrivateKeyFile().toURI().toString()) + .withProperty("grpc.tls.keyCertChain", server.getCertificateFile().toURI().toString()); + }, + port -> { + var sslContext = GrpcSslContexts.forClient() + .build(); + + var channel = NettyChannelBuilder + .forAddress("localhost", port) + .sslContext(sslContext) + .build(); + + assertThatThrownBy(() -> publishWith(channel)) + .asInstanceOf(type(StatusRuntimeException.class)) + .satisfies(Throwable::printStackTrace) + .returns(Status.Code.UNAVAILABLE, it -> it.getStatus().getCode()); + } + ); + } + + @Test + public void mTLS() { + GeneratedCert rootCA = createCertificate("ca", null, true); + GeneratedCert server = createCertificate("localhost", rootCA, false); + GeneratedCert client = createCertificate("localhost", rootCA, false); + withApp( + app -> { + app + .withProperty("grpc.tls.key", server.getPrivateKeyFile().toURI().toString()) + .withProperty("grpc.tls.keyCertChain", server.getCertificateFile().toURI().toString()) + .withProperty("grpc.tls.trustCert", rootCA.getCertificateFile().toURI().toString()); + }, + port -> { + var sslContext = GrpcSslContexts.forClient() + .trustManager(rootCA.getCertificateFile()) + .keyManager(client.getCertificateFile(), client.getPrivateKeyFile()) + .build(); + + var channel = NettyChannelBuilder + .forAddress("localhost", port) + .sslContext(sslContext) + .build(); + + publishWith(channel); + } + ); + } + + @Test + public void shouldFailOnMutualTLSWithMissingCertClient() { + GeneratedCert rootCA = createCertificate("ca", null, true); + GeneratedCert server = createCertificate("localhost", rootCA, false); + withApp( + app -> { + app + .withProperty("grpc.tls.key", server.getPrivateKeyFile().toURI().toString()) + .withProperty("grpc.tls.keyCertChain", server.getCertificateFile().toURI().toString()) + .withProperty("grpc.tls.trustCert", rootCA.getCertificateFile().toURI().toString()); + }, + port -> { + var sslContext = GrpcSslContexts.forClient() + .trustManager(rootCA.getCertificateFile()) + .build(); + + var channel = NettyChannelBuilder + .forAddress("localhost", port) + .sslContext(sslContext) + .build(); + + assertThatThrownBy(() -> publishWith(channel)) + .asInstanceOf(type(StatusRuntimeException.class)) + .satisfies(Throwable::printStackTrace) + .returns(Status.Code.UNAVAILABLE, it -> it.getStatus().getCode()); + } + ); + } + + @Test + public void shouldFailOnMutualTLSWithWrongCertClient() { + GeneratedCert rootCA = createCertificate("ca", null, true); + GeneratedCert server = createCertificate("localhost", rootCA, false); + GeneratedCert client = createCertificate("localhost", null, false); + withApp( + app -> { + app + .withProperty("grpc.tls.key", server.getPrivateKeyFile().toURI().toString()) + .withProperty("grpc.tls.keyCertChain", server.getCertificateFile().toURI().toString()) + .withProperty("grpc.tls.trustCert", rootCA.getCertificateFile().toURI().toString()); + }, + port -> { + var sslContext = GrpcSslContexts.forClient() + .trustManager(rootCA.getCertificateFile()) + .keyManager(client.getCertificateFile(), client.getPrivateKeyFile()) + .build(); + + var channel = NettyChannelBuilder + .forAddress("localhost", port) + .sslContext(sslContext) + .build(); + + assertThatThrownBy(() -> publishWith(channel)) + .asInstanceOf(type(StatusRuntimeException.class)) + .satisfies(Throwable::printStackTrace) + .returns(Status.Code.UNAVAILABLE, it -> it.getStatus().getCode()); + } + ); + } + + @SneakyThrows + private void withApp(Consumer applicationRunnerConsumer, ThrowingConsumer portConsumer) { + var applicationRunner = new ApplicationRunner("MEMORY", "MEMORY") + .withProperty("grpc.enabled", true) + .withProperty("grpc.port", 0); + applicationRunnerConsumer.accept(applicationRunner); + try (var app = applicationRunner.run()) { + portConsumer.accept(getGRPCPort(app)); + } + } + + private void publishWith(ManagedChannel channel) { + var event = PublishRequest.newBuilder() + .setTopic("authorized") + .setLiiklusEvent( + LiiklusEvent.newBuilder() + .setId(UUID.randomUUID().toString()) + .setType("com.example.event") + .setSource("/tests") + .build() + ) + .build(); + + var client = new GRPCLiiklusClient(channel); + client.publish(event).block(); + } + + @Value + static class GeneratedCert { + PrivateKey privateKey; + File privateKeyFile; + + X509Certificate certificate; + File certificateFile; + } + + @SneakyThrows + private GeneratedCert createCertificate(String cnName, GeneratedCert issuer, boolean isCA) { + var certKeyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + var name = new X500Name("CN=" + cnName); + + X500Name issuerName; + PrivateKey issuerKey; + if (issuer == null) { + issuerName = name; + issuerKey = certKeyPair.getPrivate(); + } else { + issuerName = new X500Name(issuer.getCertificate().getSubjectDN().getName()); + issuerKey = issuer.getPrivateKey(); + } + + var builder = new JcaX509v3CertificateBuilder( + issuerName, + BigInteger.valueOf(System.currentTimeMillis()), + new Date(), + new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1)), + name, + certKeyPair.getPublic() + ); + + if (isCA) { + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(isCA)); + } + + var keyFile = File.createTempFile("key", ".key"); + try (var writer = new JcaPEMWriter(new FileWriter(keyFile))) { + writer.writeObject(new PemObject("RSA PRIVATE KEY", certKeyPair.getPrivate().getEncoded())); + } + var certificate = new JcaX509CertificateConverter().getCertificate( + builder.build( + new JcaContentSignerBuilder("SHA256WithRSA").build(issuerKey) + ) + ); + + var certFile = File.createTempFile("cert", ".pem"); + try (var writer = new JcaPEMWriter(new FileWriter(certFile))) { + writer.writeObject(certificate); + } + + return new GeneratedCert( + certKeyPair.getPrivate(), + keyFile, + certificate, + certFile + ); + } +}