diff --git a/.gitignore b/.gitignore index 68ad7d7..b9fec80 100644 --- a/.gitignore +++ b/.gitignore @@ -291,3 +291,10 @@ __pycache__/ # Docker test packages dockertests/app-packages/ + +# Test TLS material is generated at runtime by keytool (see TestTlsMaterial); +# never commit private keys or keystores. +src/test/resources/grpc-tls/ +*.p12 +*.pfx +*-key.pem diff --git a/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java b/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java index 95c6d81..8f2e82e 100644 --- a/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java +++ b/src/test/java/com/microsoft/azure/functions/worker/functional/tests/GrpcTransportTest.java @@ -1,6 +1,6 @@ package com.microsoft.azure.functions.worker.functional.tests; -import java.net.*; +import java.nio.file.Path; import java.util.concurrent.*; import javax.net.ssl.*; @@ -12,8 +12,6 @@ public class GrpcTransportTest extends FunctionsTestBase { private static final String RETURN_VALUE = "transport-ok"; - private static final String TRUSTSTORE_RESOURCE = "grpc-tls/localhost-truststore.p12"; - private static final String TRUSTSTORE_PASSWORD = "changeit"; public String ReturnStringFunction() { return RETURN_VALUE; @@ -33,7 +31,7 @@ public void legacyPlaintextTransportStillWorks() throws Exception { @Test public void trustedHttpsFunctionsUriConnectsToTlsHost() throws Exception { try (SkipTestingScope ignored = SkipTestingScope.enable(); - TrustStoreScope ignoredTrustStore = TrustStoreScope.use(TRUSTSTORE_RESOURCE, TRUSTSTORE_PASSWORD, "PKCS12"); + TrustStoreScope ignoredTrustStore = TrustStoreScope.use(); FunctionsTestHost host = new FunctionsTestHost(FunctionsTestHost.ServerTransport.TLS, FunctionsTestHost.ClientTransport.HTTPS)) { InvocationResponse response = this.invokeReturnString(host, "tls-function", "tls-request"); @@ -80,22 +78,15 @@ private TrustStoreScope(String originalTrustStore, String originalTrustStorePass this.originalTrustStoreType = originalTrustStoreType; } - static TrustStoreScope use(String resourceName, String password, String storeType) { + static TrustStoreScope use() { String originalTrustStore = System.getProperty("javax.net.ssl.trustStore"); String originalTrustStorePassword = System.getProperty("javax.net.ssl.trustStorePassword"); String originalTrustStoreType = System.getProperty("javax.net.ssl.trustStoreType"); - URL resource = GrpcTransportTest.class.getClassLoader().getResource(resourceName); - if (resource == null) { - throw new IllegalStateException("Missing TLS truststore resource: " + resourceName); - } - try { - System.setProperty("javax.net.ssl.trustStore", new java.io.File(resource.toURI()).getAbsolutePath()); - } catch (URISyntaxException ex) { - throw new IllegalStateException("Invalid TLS truststore resource path: " + resourceName, ex); - } - System.setProperty("javax.net.ssl.trustStorePassword", password); - System.setProperty("javax.net.ssl.trustStoreType", storeType); + Path trustStore = TestTlsMaterial.getInstance().trustStorePath(); + System.setProperty("javax.net.ssl.trustStore", trustStore.toAbsolutePath().toString()); + System.setProperty("javax.net.ssl.trustStorePassword", TestTlsMaterial.PASSWORD); + System.setProperty("javax.net.ssl.trustStoreType", TestTlsMaterial.STORE_TYPE); return new TrustStoreScope(originalTrustStore, originalTrustStorePassword, originalTrustStoreType); } diff --git a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java index 78d73a0..f6c4748 100644 --- a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java +++ b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/FunctionsTestHost.java @@ -3,6 +3,10 @@ import java.io.*; import java.net.*; import java.nio.file.*; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.*; @@ -17,6 +21,8 @@ import com.microsoft.azure.functions.rpc.messages.*; import io.grpc.*; import io.grpc.netty.shaded.io.grpc.netty.*; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; import io.grpc.stub.*; import org.apache.commons.lang3.tuple.*; @@ -34,9 +40,6 @@ public enum ServerTransport { private static final int RESPONSE_TIMEOUT_SECONDS = 10; private static final long RESPONSE_POLL_MILLIS = 100L; - private static final String TLS_RESOURCE_ROOT = "grpc-tls/"; - private static final String TLS_CERTIFICATE_RESOURCE = TLS_RESOURCE_ROOT + "localhost-cert.pem"; - private static final String TLS_PRIVATE_KEY_RESOURCE = TLS_RESOURCE_ROOT + "localhost-key.pem"; private int port; public FunctionsTestHost() throws Exception { @@ -67,16 +70,30 @@ private int populatePort() { } @PostConstruct - private void initializeServer() throws IOException { + private void initializeServer() throws GeneralSecurityException, IOException { ServerBuilder> builder = this.serverTransport == ServerTransport.TLS - ? NettyServerBuilder.forPort(this.getPort()) - .sslContext(GrpcSslContexts.forServer(getTlsResource(TLS_CERTIFICATE_RESOURCE), getTlsResource(TLS_PRIVATE_KEY_RESOURCE)).build()) + ? NettyServerBuilder.forPort(this.getPort()).sslContext(buildServerSslContext()) : ServerBuilder.forPort(this.getPort()); this.grpcHost = new HostGrpcImplementation(); this.server = builder.addService(this.grpcHost).build(); this.server.start(); } + private SslContext buildServerSslContext() throws GeneralSecurityException, IOException { + TestTlsMaterial material = TestTlsMaterial.getInstance(); + KeyStore keyStore = KeyStore.getInstance(TestTlsMaterial.STORE_TYPE); + try (InputStream in = Files.newInputStream(material.serverKeyStorePath())) { + keyStore.load(in, TestTlsMaterial.PASSWORD.toCharArray()); + } + PrivateKey privateKey = (PrivateKey) keyStore.getKey(TestTlsMaterial.ALIAS, TestTlsMaterial.PASSWORD.toCharArray()); + java.security.cert.Certificate[] chain = keyStore.getCertificateChain(TestTlsMaterial.ALIAS); + X509Certificate[] certificates = new X509Certificate[chain.length]; + for (int i = 0; i < chain.length; i++) { + certificates[i] = (X509Certificate) chain[i]; + } + return GrpcSslContexts.configure(SslContextBuilder.forServer(privateKey, certificates)).build(); + } + @PostConstruct private void initializeClient() throws Exception { this.client = new JavaWorkerClient(this); @@ -158,18 +175,6 @@ private void closeQuietly() { } } - private static File getTlsResource(String resourcePath) { - URL resource = FunctionsTestHost.class.getClassLoader().getResource(resourcePath); - if (resource == null) { - throw new IllegalStateException("Missing test TLS resource: " + resourcePath); - } - try { - return Paths.get(resource.toURI()).toFile(); - } catch (URISyntaxException ex) { - throw new IllegalStateException("Invalid test TLS resource path: " + resourcePath, ex); - } - } - private void throwIfListeningFailed() throws ExecutionException, InterruptedException { if (this.listeningTask != null && this.listeningTask.isDone()) { this.listeningTask.get(); diff --git a/src/test/java/com/microsoft/azure/functions/worker/test/utilities/TestTlsMaterial.java b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/TestTlsMaterial.java new file mode 100644 index 0000000..8f0ac0a --- /dev/null +++ b/src/test/java/com/microsoft/azure/functions/worker/test/utilities/TestTlsMaterial.java @@ -0,0 +1,181 @@ +package com.microsoft.azure.functions.worker.test.utilities; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * Generates ephemeral TLS material (a self-signed {@code CN=localhost} certificate, + * its private key, and a matching truststore) for the gRPC transport tests. + * + *
The material is produced at runtime with the JDK's {@code keytool} so that no + * private keys are committed to the repository. {@code keytool} ships with every + * JDK (8 through 25), which keeps this portable across the CI Java matrix without + * pulling in a certificate-generation dependency such as Bouncy Castle.
+ * + *A single instance is shared across the test server and client so that the + * server's certificate is trusted by the client. All files are written to a + * temporary directory that is deleted when the JVM exits.
+ */ +public final class TestTlsMaterial { + + /** Fixed alias/password; this is throwaway, per-run material with no security value. */ + public static final String ALIAS = "localhost"; + public static final String PASSWORD = "changeit"; + public static final String STORE_TYPE = "PKCS12"; + + private static final long KEYTOOL_TIMEOUT_SECONDS = 60; + + private static volatile TestTlsMaterial instance; + + private final Path serverKeyStore; + private final Path trustStore; + + private TestTlsMaterial() { + try { + Path directory = Files.createTempDirectory("grpc-tls-test"); + directory.toFile().deleteOnExit(); + + this.serverKeyStore = directory.resolve("server.p12"); + this.trustStore = directory.resolve("truststore.p12"); + Path certificate = directory.resolve("localhost-cert.pem"); + + generateKeyPair(this.serverKeyStore); + exportCertificate(this.serverKeyStore, certificate); + importCertificate(certificate, this.trustStore); + + this.serverKeyStore.toFile().deleteOnExit(); + this.trustStore.toFile().deleteOnExit(); + certificate.toFile().deleteOnExit(); + } catch (IOException | InterruptedException ex) { + if (ex instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + throw new IllegalStateException("Failed to generate test TLS material", ex); + } + } + + public static TestTlsMaterial getInstance() { + TestTlsMaterial local = instance; + if (local == null) { + synchronized (TestTlsMaterial.class) { + local = instance; + if (local == null) { + local = new TestTlsMaterial(); + instance = local; + } + } + } + return local; + } + + /** Path to the PKCS12 keystore holding the server's private key and certificate. */ + public Path serverKeyStorePath() { + return this.serverKeyStore; + } + + /** Path to the PKCS12 truststore holding the server's certificate (for the client). */ + public Path trustStorePath() { + return this.trustStore; + } + + private static void generateKeyPair(Path keyStore) throws IOException, InterruptedException { + runKeytool( + "-genkeypair", + "-alias", ALIAS, + "-keyalg", "RSA", + "-keysize", "2048", + // Short-lived: the material only needs to outlive a single test run. + "-validity", "2", + "-dname", "CN=localhost", + "-ext", "san=dns:localhost,ip:127.0.0.1", + "-keystore", keyStore.toString(), + "-storetype", STORE_TYPE, + "-storepass", PASSWORD, + "-keypass", PASSWORD); + } + + private static void exportCertificate(Path keyStore, Path certificate) throws IOException, InterruptedException { + runKeytool( + "-exportcert", + "-rfc", + "-alias", ALIAS, + "-keystore", keyStore.toString(), + "-storetype", STORE_TYPE, + "-storepass", PASSWORD, + "-file", certificate.toString()); + } + + private static void importCertificate(Path certificate, Path trustStore) throws IOException, InterruptedException { + runKeytool( + "-importcert", + "-noprompt", + "-alias", ALIAS, + "-file", certificate.toString(), + "-keystore", trustStore.toString(), + "-storetype", STORE_TYPE, + "-storepass", PASSWORD); + } + + private static void runKeytool(String... arguments) throws IOException, InterruptedException { + List