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 command = new ArrayList<>(); + command.add(keytoolPath()); + for (String argument : arguments) { + command.add(argument); + } + + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + + // Drain output on a background thread so a misbehaving keytool cannot block + // us indefinitely; the timeout below then governs the overall wait. + StringBuilder output = new StringBuilder(); + Thread drainer = new Thread(() -> { + try (InputStream in = process.getInputStream()) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int read; + while ((read = in.read(chunk)) != -1) { + buffer.write(chunk, 0, read); + } + synchronized (output) { + output.append(new String(buffer.toByteArray(), StandardCharsets.UTF_8)); + } + } catch (IOException ignored) { + // Output is best-effort; failures are surfaced via the exit code below. + } + }); + drainer.setDaemon(true); + drainer.start(); + + if (!process.waitFor(KEYTOOL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IOException("keytool timed out running: " + arguments[0]); + } + drainer.join(TimeUnit.SECONDS.toMillis(5)); + + if (process.exitValue() != 0) { + synchronized (output) { + throw new IOException( + "keytool " + arguments[0] + " failed (exit " + process.exitValue() + "):\n" + output); + } + } + } + + private static String keytoolPath() throws IOException { + String javaHome = System.getProperty("java.home"); + boolean windows = System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win"); + Path keytool = Paths.get(javaHome, "bin", windows ? "keytool.exe" : "keytool"); + if (!Files.isExecutable(keytool)) { + throw new IOException("keytool not found or not executable at: " + keytool); + } + return keytool.toString(); + } +} diff --git a/src/test/resources/grpc-tls/localhost-cert.pem b/src/test/resources/grpc-tls/localhost-cert.pem deleted file mode 100644 index 4d9d070..0000000 --- a/src/test/resources/grpc-tls/localhost-cert.pem +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDHzCCAgegAwIBAgIUUT0dnmqskpKcIS3wt+Fq+obG5GEwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDUxMjE3MjExMFoXDTM2MDUw -OTE3MjExMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF -AAOCAQ8AMIIBCgKCAQEAmRbV1NPyunri/+EN14nfIQhyY7grIRmUS5XTvsJhVQMg -/vP0rZ1H1I8f7mzkmyfDsB0i5putmz01lAHD5/Wfa0JEhVgmnwK20RsufjsWhwpY -tVpbfzgY5LJILSwz5Qw//Q2VDFiFXK7plVkfnmwrutUQ4AXuk55IuLDHlHxAI7vt -v/MxxlCri+EeoZcmTTVkLeMvtTf7Pw3upc8PBNNDYJOJvld4DXxz4e93ZhFmQGck -QMVeGGPsL+jmhu78pdfW+TcwlEJTvKBZ8xYTGaQbks6ZwXslYvuL0mI7XEajTSrM -Pw7vJeaJH85fETtZNfz3/IR4or88pyQQN9Cv0fY3xQIDAQABo2kwZzAdBgNVHQ4E -FgQUQgk8tVR0g3rgA6MRktL39pScY9UwHwYDVR0jBBgwFoAUQgk8tVR0g3rgA6MR -ktL39pScY9UwDwYDVR0TAQH/BAUwAwEB/zAUBgNVHREEDTALgglsb2NhbGhvc3Qw -DQYJKoZIhvcNAQELBQADggEBAJg1IQoiIV8qoLpVLbY5pvgkeClmFQsVwFTB0rLS -lU4ZkVkoIiYowQnb10WFpGdyhfI+WhZcLev2Dmm7PJ9Jp0H0vC5U0ebNLzMJdwjr -HjJcIuueYJDJ3YelEIzO2qa4R977PCvBPLHEi7/KRdSbAOz6lf6Yp+OPe46gJHP1 -XwaKt628oPd7A4FdBTF7lUfcLdPUnW9Glhg7VfB6aX6o5+Mup+QEt96xlXg6Ua2L -xYw8AJ75rAmQLwu05uzyaTGlHE7BXtsP+bEBAz4CXVv1z5i1UJfB6N/aWB1VOHKN -2r1x1fMUCNLpsoTdtmkwAmkmzx4bfyWtIIK+hAxTm7bmjGk= ------END CERTIFICATE----- diff --git a/src/test/resources/grpc-tls/localhost-key.pem b/src/test/resources/grpc-tls/localhost-key.pem deleted file mode 100644 index 976e744..0000000 --- a/src/test/resources/grpc-tls/localhost-key.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZFtXU0/K6euL/ -4Q3Xid8hCHJjuCshGZRLldO+wmFVAyD+8/StnUfUjx/ubOSbJ8OwHSLmm62bPTWU -AcPn9Z9rQkSFWCafArbRGy5+OxaHCli1Wlt/OBjkskgtLDPlDD/9DZUMWIVcrumV -WR+ebCu61RDgBe6Tnki4sMeUfEAju+2/8zHGUKuL4R6hlyZNNWQt4y+1N/s/De6l -zw8E00Ngk4m+V3gNfHPh73dmEWZAZyRAxV4YY+wv6OaG7vyl19b5NzCUQlO8oFnz -FhMZpBuSzpnBeyVi+4vSYjtcRqNNKsw/Du8l5okfzl8RO1k1/Pf8hHiivzynJBA3 -0K/R9jfFAgMBAAECggEANvM/Zdl+MvmRKY+6zDcs5EqH5Mtij8sCs+7fxoU3MrCg -02L13Kur8Nw+9fIYTKkFUN3kfSo8MpDR/oJzs3sy8ekjd0mg80qiHITJN342I9rO -5Km+Vffo14424iAPsJOpFEgfzAKqPA58waLv+omRWMrJ99+pN0uFhuXNfbrruudX -zgCMWtw7gqciTEOrcy2GLyKOClfg6AIn/9F1Z3yrQBrHW7TZjPhyHZi3PeIeXAz4 -hVjj5zHP8GI8P4O/kP6gFfB5x/1PXGErGOSl3HOieKse7JTMu7NCtgLEEshFAjOd -z4EsAGzJb0FdyH1eX9+WhsvVkbTTnek3fhBCMIJ/MQKBgQDLvpbTbRLdbxH6pDIx -I53jBpDU1x+QU5j1lvLZJtvdMnsJFA43zamSqwmIzuIAs9POzEGIP586TCr5PXBi -NrAz9mYZC1ye59ZFq0JVundbCWEO+YxuR5glgqjubWdfwFzFynCyuuZTqmPozGnY -wSOjIE7g0Vw6aJVXJq0eXG1EAwKBgQDAWlUSyXSVZ7INXFr+Dh0gd12HhQMYmxEz -twtXEfZTGwHqlxZOo0/G/LYVWPdIqkUw86aaKorlfgg5yzbJ6tfgxiE71W7VPiBR -X9Cefz16th2rQfM6Y1fszimV1Dpf3HU5TuN/ZK4tlEqsfK01yfjofPCjNL1c2BQr -sHYkUi5elwKBgBy8LZN2D7IRRyzdWYLarhrlwylxia8WSz1f47JCq8GfrACUxoiS -Rfc8jiSwYOmOczH4Vsm7h152fZ0XUDFZ2zII709a7d4vfmXnCH0Exm6dfQXapjar -fEbWDbNK1MiJXcw7h/d9KpzkLCEaK1d5regE13sXq/VE6MMY3lOo33Q3AoGAamsP -mh8+ktIV3fJ0nQ3t62JeqnVaayiPcc8ZRQi5AO12N/Vy7/rGTk7N5i2cUeVx9k02 -pSBYS/NYVbEqFLgKy16SUGoasXt3oc2iu62ls9hBvdf02x7PLEI7G5uY2CQ97oDI -uFhZTPo3/gnUQmgFf4pwD7tD8LPTJQCxvBKDeO0CgYEApeC2lxH4FekJEA4iIBG+ -InRXX20QCLqI2d4xMOHHDEWaulhDEkjs9HEErA+3HpkK5hOTsEIU6GENrpc3P2JY -iFaO/gJiFEErkXsSaDRRaROTKI27qA0JPWzrfDHH4G59qpVVH6KWIYJvWbsHHo49 -MSFfMNCXhE9polVRU6EyWb0= ------END PRIVATE KEY----- diff --git a/src/test/resources/grpc-tls/localhost-truststore.p12 b/src/test/resources/grpc-tls/localhost-truststore.p12 deleted file mode 100644 index cb4c21c..0000000 Binary files a/src/test/resources/grpc-tls/localhost-truststore.p12 and /dev/null differ