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..4879f17ae0 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,8 @@ package com.netflix.spinnaker.orca.webhook.config; +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 +27,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 +54,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 +75,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 +88,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 +108,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 +124,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,18 +166,41 @@ 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(); + private Optional getCustomIdentity() throws IOException { + var identitySettings = webhookProperties.getIdentity(); + if (identitySettings == null + || !identitySettings.isEnabled() + || StringUtils.isEmpty(identitySettings.getIdentityStore())) { + return Optional.empty(); + } + + 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.getIdentityKeyPassword(); + return password == null ? new char[0] : password.toCharArray(); + }); + return Optional.of(identity.load()); + } + + private Optional getCustomTrustStore() { + var trustSettings = webhookProperties.getTrust(); if (trustSettings == null || !trustSettings.isEnabled() || StringUtils.isEmpty(trustSettings.getTrustStore())) { @@ -156,7 +209,7 @@ private Optional getCustomKeyStore() { KeyStore keyStore; try { - keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore = KeyStore.getInstance(trustSettings.getTrustStoreType()); } catch (KeyStoreException e) { throw new RuntimeException(e); } 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..eb7f4da6e3 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,34 @@ 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; + private String trustStoreType = "PKCS12"; + } + + @Data + @NoArgsConstructor + public static class IdentitySettings { + private boolean enabled; + private String identityStore; + private String identityStorePassword; + private String identityKeyPassword; + private String identityStoreType = "PKCS12"; } @Data diff --git a/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationTest.java b/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationTest.java new file mode 100644 index 0000000000..4f16fe3569 --- /dev/null +++ b/orca-webhook/src/test/java/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationTest.java @@ -0,0 +1,280 @@ +/* + * 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.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spinnaker.config.OkHttp3ClientConfiguration; +import com.netflix.spinnaker.config.OkHttpClientComponents; +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 com.netflix.spinnaker.orca.pipeline.model.StageExecutionImpl; +import com.netflix.spinnaker.orca.webhook.service.WebhookService; +import java.io.ByteArrayInputStream; +import java.io.File; +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 java.util.HashMap; +import java.util.Map; +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.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.task.TaskExecutorBuilder; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +@SpringBootTest( + classes = { + WebhookConfiguration.class, + MtlsConfigurationTest.TestConfiguration.class, + WebhookService.class, + OkHttpClientComponents.class, + OkHttp3ClientConfiguration.class + }) +class MtlsConfigurationTest { + + private static final String password = "password"; + + // Tempfiles to store our keystores + private static File caStoreFile; + private static File clientIdentityStoreFile; + + private static MockWebServer mockWebServer; + private static final ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().build(); + + @Configuration + static class TestConfiguration { + @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; + + @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.setIdentityKeyPassword(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; + } + } + + @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 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; + } + + @Autowired WebhookService service; + + @BeforeAll + @SneakyThrows + public static void beforeAll() throws IOException { + // Create tempfiles + caStoreFile = File.createTempFile("testca", ""); + clientIdentityStoreFile = File.createTempFile("testid", ""); + + // 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); + + // Create a client identity signed by our invented CA + var clientIdentityKeyPair = createKeyPair(); + var clientIdentityCert = + createCertificate( + new X500Name("CN=client"), + caKeyPair.getPrivate(), + clientIdentityKeyPair.getPublic(), + false); + writeIdentityStore( + clientIdentityKeyPair.getPrivate(), + new X509Certificate[] {clientIdentityCert, caCert}, + clientIdentityStoreFile); + + // Create a server identity signed by our invented CA + var serverIdentityKeyPair = createKeyPair(); + var serverIdentityCert = + createCertificate( + new X500Name("CN=server"), + caKeyPair.getPrivate(), + serverIdentityKeyPair.getPublic(), + false); + + // 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(), new X509Certificate[] {serverIdentityCert}); + 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(); + } + + @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")); + } +}