diff --git a/orca-webhook/orca-webhook.gradle b/orca-webhook/orca-webhook.gradle index 0cf929e10a..f4497c0268 100644 --- a/orca-webhook/orca-webhook.gradle +++ b/orca-webhook/orca-webhook.gradle @@ -24,11 +24,20 @@ dependencies { implementation("io.spinnaker.kork:kork-web") implementation("org.springframework.boot:spring-boot-autoconfigure") compileOnly("org.projectlombok:lombok") + testCompileOnly("org.projectlombok:lombok") annotationProcessor("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") implementation("com.jayway.jsonpath:json-path") implementation("com.squareup.okhttp3:okhttp") implementation("com.jakewharton.retrofit:retrofit1-okhttp3-client:1.1.0") + + testImplementation("com.squareup.okhttp3:mockwebserver") + testImplementation("io.spinnaker.kork:kork-test") + testImplementation("org.bouncycastle:bcpkix-jdk18on") + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.mockito:mockito-core") testImplementation("org.springframework:spring-test") + testImplementation("org.springframework.boot:spring-boot-test") testImplementation("org.apache.groovy:groovy-json") testRuntimeOnly("net.bytebuddy:byte-buddy") diff --git a/orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookConfiguration.java b/orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookConfiguration.java index 8a851173ae..61ef6b90c9 100644 --- a/orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookConfiguration.java +++ b/orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookConfiguration.java @@ -17,6 +17,9 @@ package com.netflix.spinnaker.orca.webhook.config; +import com.netflix.spinnaker.kork.crypto.TrustStores; +import com.netflix.spinnaker.kork.crypto.X509Identity; +import com.netflix.spinnaker.kork.crypto.X509IdentitySource; import com.netflix.spinnaker.okhttp.OkHttpClientConfigurationProperties; import com.netflix.spinnaker.orca.config.UserConfiguredUrlRestrictions; import com.netflix.spinnaker.orca.webhook.util.UnionX509TrustManager; @@ -25,13 +28,14 @@ import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.Charset; +import java.nio.file.Path; import java.security.KeyManagementException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; import java.util.ArrayList; -import java.util.List; import java.util.Map; import java.util.Optional; import javax.net.ssl.*; @@ -51,7 +55,6 @@ import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.http.client.OkHttp3ClientHttpRequestFactory; import org.springframework.http.converter.AbstractHttpMessageConverter; -import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.http.converter.StringHttpMessageConverter; @@ -73,9 +76,9 @@ public WebhookConfiguration(WebhookProperties webhookProperties) { @Bean @ConditionalOnMissingBean(RestTemplate.class) public RestTemplate restTemplate(ClientHttpRequestFactory webhookRequestFactory) { - RestTemplate restTemplate = new RestTemplate(webhookRequestFactory); + var restTemplate = new RestTemplate(webhookRequestFactory); - List> converters = restTemplate.getMessageConverters(); + var converters = restTemplate.getMessageConverters(); converters.add(new ObjectStringHttpMessageConverter()); converters.add(new MapToStringHttpMessageConverter()); restTemplate.setMessageConverters(converters); @@ -86,10 +89,11 @@ public RestTemplate restTemplate(ClientHttpRequestFactory webhookRequestFactory) @Bean public ClientHttpRequestFactory webhookRequestFactory( OkHttpClientConfigurationProperties okHttpClientConfigurationProperties, - UserConfiguredUrlRestrictions userConfiguredUrlRestrictions) { - X509TrustManager trustManager = webhookX509TrustManager(); - SSLSocketFactory sslSocketFactory = getSSLSocketFactory(trustManager); - OkHttpClient client = + UserConfiguredUrlRestrictions userConfiguredUrlRestrictions) + throws IOException { + var trustManager = webhookX509TrustManager(); + var sslSocketFactory = getSSLSocketFactory(trustManager); + var builder = new OkHttpClient.Builder() .sslSocketFactory(sslSocketFactory, trustManager) .addNetworkInterceptor( @@ -105,9 +109,14 @@ public ClientHttpRequestFactory webhookRequestFactory( } return response; - }) - .build(); - OkHttp3ClientHttpRequestFactory requestFactory = new OkHttp3ClientHttpRequestFactory(client); + }); + + if (webhookProperties.isInsecureSkipHostnameVerification()) { + builder.hostnameVerifier((hostname, session) -> true); + } + + var client = builder.build(); + var requestFactory = new OkHttp3ClientHttpRequestFactory(client); requestFactory.setReadTimeout( Math.toIntExact(okHttpClientConfigurationProperties.getReadTimeoutMs())); requestFactory.setConnectTimeout( @@ -116,19 +125,41 @@ public ClientHttpRequestFactory webhookRequestFactory( } private X509TrustManager webhookX509TrustManager() { - List trustManagers = new ArrayList<>(); + var trustManagers = new ArrayList(); trustManagers.add(getTrustManager(null)); - getCustomKeyStore().ifPresent(keyStore -> trustManagers.add(getTrustManager(keyStore))); + getCustomTrustStore().ifPresent(keyStore -> trustManagers.add(getTrustManager(keyStore))); + + if (webhookProperties.isInsecureTrustSelfSigned()) { + trustManagers.add( + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) {} + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) {} + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }); + } return new UnionX509TrustManager(trustManagers); } - private SSLSocketFactory getSSLSocketFactory(X509TrustManager trustManager) { + private SSLSocketFactory getSSLSocketFactory(X509TrustManager trustManager) throws IOException { try { - SSLContext sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, new X509TrustManager[] {trustManager}, null); - return sslContext.getSocketFactory(); + var identityOpt = getCustomIdentity(); + if (identityOpt.isPresent()) { + var identity = identityOpt.get(); + return identity.createSSLContext(trustManager).getSocketFactory(); + } else { + var sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new X509TrustManager[] {trustManager}, null); + return sslContext.getSocketFactory(); + } } catch (KeyManagementException | NoSuchAlgorithmException e) { throw new RuntimeException(e); } @@ -136,38 +167,81 @@ private SSLSocketFactory getSSLSocketFactory(X509TrustManager trustManager) { private X509TrustManager getTrustManager(KeyStore keyStore) { try { - TrustManagerFactory trustManagerFactory = + var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); trustManagerFactory.init(keyStore); - TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + var trustManagers = trustManagerFactory.getTrustManagers(); return (X509TrustManager) trustManagers[0]; } catch (KeyStoreException | NoSuchAlgorithmException e) { throw new RuntimeException(e); } } - private Optional getCustomKeyStore() { - WebhookProperties.TrustSettings trustSettings = webhookProperties.getTrust(); - if (trustSettings == null - || !trustSettings.isEnabled() - || StringUtils.isEmpty(trustSettings.getTrustStore())) { + private Optional getCustomIdentity() throws IOException { + var identitySettings = webhookProperties.getIdentity(); + if (identitySettings == null || !identitySettings.isEnabled()) { return Optional.empty(); } - KeyStore keyStore; - try { - keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - } catch (KeyStoreException e) { - throw new RuntimeException(e); + if (StringUtils.isNotEmpty(identitySettings.getIdentityStore())) { + var identity = + X509IdentitySource.fromKeyStore( + Path.of(identitySettings.getIdentityStore()), + identitySettings.getIdentityStoreType(), + () -> { + var password = identitySettings.getIdentityStorePassword(); + return password == null ? new char[0] : password.toCharArray(); + }, + () -> { + var password = identitySettings.getIdentityStoreKeyPassword(); + return password == null ? new char[0] : password.toCharArray(); + }); + return Optional.of(identity.load()); + } else if (StringUtils.isNotEmpty(identitySettings.getIdentityKeyPem())) { + return Optional.of( + X509IdentitySource.fromPEM( + Path.of(identitySettings.getIdentityKeyPem()), + Path.of(identitySettings.getIdentityCertPem())) + .load()); } - try (FileInputStream file = new FileInputStream(trustSettings.getTrustStore())) { - keyStore.load(file, trustSettings.getTrustStorePassword().toCharArray()); - } catch (CertificateException | IOException | NoSuchAlgorithmException e) { - throw new RuntimeException(e); + return Optional.empty(); + } + + private Optional getCustomTrustStore() { + var trustSettings = webhookProperties.getTrust(); + if (trustSettings == null || !trustSettings.isEnabled()) { + return Optional.empty(); + } + + // Use keystore first if set, then try PEM + if (StringUtils.isNotEmpty(trustSettings.getTrustStore())) { + KeyStore keyStore; + try { + keyStore = KeyStore.getInstance(trustSettings.getTrustStoreType()); + } catch (KeyStoreException e) { + throw new RuntimeException(e); + } + + try (FileInputStream file = new FileInputStream(trustSettings.getTrustStore())) { + keyStore.load(file, trustSettings.getTrustStorePassword().toCharArray()); + } catch (CertificateException | IOException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + + return Optional.of(keyStore); + } else if (StringUtils.isNotEmpty(trustSettings.getTrustPem())) { + try { + return Optional.of(TrustStores.loadPEM(Path.of(trustSettings.getTrustPem()))); + } catch (CertificateException + | IOException + | NoSuchAlgorithmException + | KeyStoreException e) { + throw new RuntimeException(e); + } } - return Optional.of(keyStore); + return Optional.empty(); } public class ObjectStringHttpMessageConverter extends StringHttpMessageConverter { diff --git a/orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookProperties.java b/orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookProperties.java index 1de1ff9c26..beca65d359 100644 --- a/orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookProperties.java +++ b/orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookProperties.java @@ -53,18 +53,39 @@ public class WebhookProperties { .collect(Collectors.toList()); private List preconfigured = new ArrayList<>(); - private TrustSettings trust; + private TrustSettings trust = new TrustSettings(); + private IdentitySettings identity = new IdentitySettings(); private boolean verifyRedirects = true; private List defaultRetryStatusCodes = List.of(429); + // For testing *only* + private boolean insecureSkipHostnameVerification = false; + private boolean insecureTrustSelfSigned = false; + @Data @NoArgsConstructor public static class TrustSettings { private boolean enabled; private String trustStore; private String trustStorePassword; + // Default as JKS instead of PKCS12 for backward compatibility + private String trustStoreType = "JKS"; + private String trustPem; + } + + @Data + @NoArgsConstructor + public static class IdentitySettings { + private boolean enabled; + private String identityStore; + private String identityStorePassword; + private String identityStoreKeyPassword; + private String identityStoreType = "PKCS12"; + + private String identityKeyPem; + private String identityCertPem; } @Data diff --git a/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationKeystoreTest.java b/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationKeystoreTest.java new file mode 100644 index 0000000000..abedc40265 --- /dev/null +++ b/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationKeystoreTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2024 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spinnaker.orca.webhook.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.netflix.spinnaker.config.OkHttp3ClientConfiguration; +import com.netflix.spinnaker.config.OkHttpClientComponents; +import com.netflix.spinnaker.orca.pipeline.model.StageExecutionImpl; +import com.netflix.spinnaker.orca.webhook.service.WebhookService; +import java.util.HashMap; +import java.util.Map; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; + +@SpringBootTest( + classes = { + MtlsConfigurationKeystoreTest.KeyStoreTestConfiguration.class, + MtlsConfigurationTestBase.TestConfigurationBase.class, + OkHttp3ClientConfiguration.class, + OkHttpClientComponents.class, + WebhookConfiguration.class, + WebhookService.class + }) +public class MtlsConfigurationKeystoreTest extends MtlsConfigurationTestBase { + static class KeyStoreTestConfiguration { + @Bean + @Primary + WebhookProperties webhookProperties() { + // Set up identity and trust properties + var props = new WebhookProperties(); + + var identity = new WebhookProperties.IdentitySettings(); + identity.setEnabled(true); + identity.setIdentityStore(clientIdentityStoreFile.getAbsolutePath()); + identity.setIdentityStorePassword(password); + identity.setIdentityStoreKeyPassword(password); + props.setIdentity(identity); + + var trust = new WebhookProperties.TrustSettings(); + trust.setEnabled(true); + trust.setTrustStore(caStoreFile.getAbsolutePath()); + trust.setTrustStorePassword(password); + props.setTrust(trust); + + // Tell okhttp to skip hostname verification, since all this is made up + props.setInsecureSkipHostnameVerification(true); + + return props; + } + } + + @Autowired WebhookService service; + + @Test + @SneakyThrows + public void mTLSConnectivityTest() { + var context = + new HashMap() { + { + put("url", mockWebServer.url("/").toString()); + put("method", HttpMethod.POST); + put("payload", "{ \"foo\": \"bar\" }"); + } + }; + + var stageExecution = new StageExecutionImpl(null, null, null, context); + var response = service.callWebhook(stageExecution); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + var body = mapper.readValue(response.getBody().toString(), Map.class); + assertEquals("yep", body.get("mtls")); + } +} diff --git a/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationPemTest.java b/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationPemTest.java new file mode 100644 index 0000000000..f4df2a98f3 --- /dev/null +++ b/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationPemTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spinnaker.orca.webhook.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.netflix.spinnaker.config.OkHttp3ClientConfiguration; +import com.netflix.spinnaker.config.OkHttpClientComponents; +import com.netflix.spinnaker.orca.pipeline.model.StageExecutionImpl; +import com.netflix.spinnaker.orca.webhook.service.WebhookService; +import java.util.HashMap; +import java.util.Map; +import lombok.SneakyThrows; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; + +@SpringBootTest( + classes = { + MtlsConfigurationPemTest.PemTestConfiguration.class, + MtlsConfigurationTestBase.TestConfigurationBase.class, + OkHttp3ClientConfiguration.class, + OkHttpClientComponents.class, + WebhookConfiguration.class, + WebhookService.class + }) +public class MtlsConfigurationPemTest extends MtlsConfigurationTestBase { + static class PemTestConfiguration { + @Bean + @Primary + WebhookProperties webhookProperties() { + // Set up identity and trust properties + var props = new WebhookProperties(); + + var identity = new WebhookProperties.IdentitySettings(); + identity.setEnabled(true); + identity.setIdentityKeyPem(clientIdentityKeyPemFile.getAbsolutePath()); + identity.setIdentityCertPem(clientIdentityCertPemFile.getAbsolutePath()); + props.setIdentity(identity); + + var trust = new WebhookProperties.TrustSettings(); + trust.setEnabled(true); + trust.setTrustPem(caPemFile.getAbsolutePath()); + props.setTrust(trust); + + // Tell okhttp to skip hostname verification, since all this is made up + props.setInsecureSkipHostnameVerification(true); + + return props; + } + } + + @Autowired WebhookService service; + + @Test + @SneakyThrows + public void mTLSConnectivityPemTest() { + var context = + new HashMap() { + { + put("url", mockWebServer.url("/").toString()); + put("method", HttpMethod.POST); + put("payload", "{ \"foo\": \"bar\" }"); + } + }; + + var stageExecution = new StageExecutionImpl(null, null, null, context); + var response = service.callWebhook(stageExecution); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + var body = mapper.readValue(response.getBody().toString(), Map.class); + assertEquals("yep", body.get("mtls")); + } +} diff --git a/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationTestBase.java b/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationTestBase.java new file mode 100644 index 0000000000..6d1e78774d --- /dev/null +++ b/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationTestBase.java @@ -0,0 +1,240 @@ +/* + * Copyright 2024 Apple, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spinnaker.orca.webhook.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spinnaker.fiat.shared.FiatService; +import com.netflix.spinnaker.kork.crypto.StandardCrypto; +import com.netflix.spinnaker.kork.crypto.StaticX509Identity; +import com.netflix.spinnaker.orca.config.UserConfiguredUrlRestrictions; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Files; +import java.security.*; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.security.spec.RSAKeyGenParameterSpec; +import java.time.Duration; +import java.util.Date; +import javax.net.ssl.X509TrustManager; +import lombok.SneakyThrows; +import okhttp3.logging.HttpLoggingInterceptor; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.openssl.jcajce.JcaPKCS8Generator; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.boot.task.TaskExecutorBuilder; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +class MtlsConfigurationTestBase { + + static final String password = "password"; + + // Tempfiles to store our keystores and PEM files + static File caStoreFile; + static File caPemFile; + + static File clientIdentityStoreFile; + static File clientIdentityKeyPemFile; + static File clientIdentityCertPemFile; + + static MockWebServer mockWebServer; + static final ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().build(); + + static class TestConfigurationBase { + @Bean + UserConfiguredUrlRestrictions userConfiguredUrlRestrictions() { + return new UserConfiguredUrlRestrictions.Builder().withRejectLocalhost(false).build(); + } + + @Bean + HttpLoggingInterceptor.Level logLevel() { + return HttpLoggingInterceptor.Level.NONE; + } + + @Bean + TaskExecutorBuilder taskExecutorBuilder() { + return new TaskExecutorBuilder(); + } + + @Bean + ObjectMapper objectMapper() { + return mapper; + } + + @MockBean FiatService fiatService; + } + + @SneakyThrows + private static KeyPair createKeyPair() { + // Create test keypair + var generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4)); + return generator.generateKeyPair(); + } + + @SneakyThrows + private static X509Certificate createCertificate( + X500Name subject, PrivateKey privateKey, PublicKey publicKey, boolean isCa) { + // Create certificate + var issuer = new X500Name("CN=ca"); + var serial = BigInteger.valueOf(System.currentTimeMillis()); + var notBefore = new Date(); + var notAfter = Date.from(notBefore.toInstant().plus(Duration.ofDays(1))); + var certificateHolderBuilder = + new JcaX509v3CertificateBuilder(issuer, serial, notBefore, notAfter, subject, publicKey); + + if (isCa) { + certificateHolderBuilder + .addExtension( + Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)) + .addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + } else { + certificateHolderBuilder + .addExtension( + Extension.keyUsage, + false, + new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment)) + .addExtension(Extension.basicConstraints, false, new BasicConstraints(false)); + } + + var certificateHolder = + certificateHolderBuilder.build( + new JcaContentSignerBuilder("SHA1withRSA").build(privateKey)); + return (X509Certificate) + StandardCrypto.getX509CertificateFactory() + .generateCertificate(new ByteArrayInputStream(certificateHolder.getEncoded())); + } + + @SneakyThrows + private static void writeKeyPem(PrivateKey privateKey, File file) { + var pemWriter = new JcaPEMWriter(new FileWriter(file)); + pemWriter.writeObject(new JcaPKCS8Generator(privateKey, null)); + pemWriter.close(); + } + + @SneakyThrows + private static void writeCertPem(X509Certificate[] certs, File file) { + var pemWriter = new JcaPEMWriter(new FileWriter(file)); + for (var cert : certs) { + pemWriter.writeObject(cert); + } + pemWriter.close(); + } + + @SneakyThrows + private static KeyStore writeTrustStore(Certificate certificate, File file) { + var store = StandardCrypto.getPKCS12KeyStore(); + store.load(null); + store.setCertificateEntry("ca", certificate); + store.store(Files.newOutputStream(file.toPath()), password.toCharArray()); + return store; + } + + @SneakyThrows + private static KeyStore writeIdentityStore( + PrivateKey privateKey, X509Certificate[] certificateChain, File file) { + var store = StandardCrypto.getPKCS12KeyStore(); + store.load(null); + store.setKeyEntry("identity", privateKey, password.toCharArray(), certificateChain); + store.store(Files.newOutputStream(file.toPath()), password.toCharArray()); + return store; + } + + @BeforeAll + @SneakyThrows + public static void beforeAll() throws IOException { + // Create tempfiles + caStoreFile = File.createTempFile("testca", ""); + caPemFile = File.createTempFile("testcapem", ""); + + clientIdentityStoreFile = File.createTempFile("testid", ""); + clientIdentityKeyPemFile = File.createTempFile("testidpemkey", ""); + clientIdentityCertPemFile = File.createTempFile("testidcertkey", ""); + + // Invent a CA that will sign our client certificate, and will be trusted by the server + var caKeyPair = createKeyPair(); + var caCert = + createCertificate( + new X500Name("CN=ca"), caKeyPair.getPrivate(), caKeyPair.getPublic(), true); + + var caStore = writeTrustStore(caCert, caStoreFile); + writeCertPem(new X509Certificate[] {caCert}, caPemFile); + + // Create a client identity signed by our invented CA + var clientIdentityKeyPair = createKeyPair(); + var clientIdentityCert = + createCertificate( + new X500Name("CN=client"), + caKeyPair.getPrivate(), + clientIdentityKeyPair.getPublic(), + false); + var clientIdentityCertChain = new X509Certificate[] {clientIdentityCert, caCert}; + + writeIdentityStore( + clientIdentityKeyPair.getPrivate(), clientIdentityCertChain, clientIdentityStoreFile); + writeKeyPem(clientIdentityKeyPair.getPrivate(), clientIdentityKeyPemFile); + writeCertPem(clientIdentityCertChain, clientIdentityCertPemFile); + + // Create a server identity signed by our invented CA + var serverIdentityKeyPair = createKeyPair(); + var serverIdentityCert = + createCertificate( + new X500Name("CN=server"), + caKeyPair.getPrivate(), + serverIdentityKeyPair.getPublic(), + false); + var serverIdentityCertChain = new X509Certificate[] {serverIdentityCert, caCert}; + + // Set server trust to our CA we just made + var serverTrustManagerFactory = StandardCrypto.getPKIXTrustManagerFactory(); + serverTrustManagerFactory.init(caStore); + var serverTrustManager = (X509TrustManager) serverTrustManagerFactory.getTrustManagers()[0]; + + // Set the server identity to the server identity we just made + var serverIdentity = + new StaticX509Identity(serverIdentityKeyPair.getPrivate(), serverIdentityCertChain); + var serverSocketFactory = + serverIdentity.createSSLContext(serverTrustManager).getSocketFactory(); + + // Configure MockWebServer + mockWebServer = new MockWebServer(); + mockWebServer.useHttps(serverSocketFactory, false); + mockWebServer.requireClientAuth(); + mockWebServer.enqueue(new MockResponse().setBody("{ \"mtls\": \"yep\" }")); + mockWebServer.start(); + } + + @AfterAll + @SneakyThrows + public static void afterAll() { + mockWebServer.shutdown(); + } +}