Skip to content
Open
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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.*;

Expand All @@ -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;
Expand All @@ -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");

Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand All @@ -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.*;

Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*/
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 {
Comment thread
ahmedmuhsin marked this conversation as resolved.
List<String> 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");
Comment thread
ahmedmuhsin marked this conversation as resolved.
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();
}
}
19 changes: 0 additions & 19 deletions src/test/resources/grpc-tls/localhost-cert.pem

This file was deleted.

28 changes: 0 additions & 28 deletions src/test/resources/grpc-tls/localhost-key.pem

This file was deleted.

Binary file not shown.