From 949cacde1574cb0ec170b284f4b00d39a2f473ba Mon Sep 17 00:00:00 2001 From: Joe Cavanagh Date: Thu, 3 Oct 2024 21:37:53 -0700 Subject: [PATCH] feat(webhook): Single-identity mTLS webhook configuration Allows configuring a single X509 identity to use as the client identity for all outgoing webhooks. The internals require an encrypted private key entry - unencrypted private keys in keystores are not supported. Similarly, keystores without passwords are also not supported. --- orca-webhook/orca-webhook.gradle | 9 + .../webhook/config/WebhookConfiguration.java | 97 ++++++-- .../webhook/config/WebhookProperties.java | 18 +- .../config/MtlsConfigurationSpec.groovy | 230 ++++++++++++++++++ 4 files changed, 331 insertions(+), 23 deletions(-) create mode 100644 orca-webhook/src/test/groovy/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationSpec.groovy diff --git a/orca-webhook/orca-webhook.gradle b/orca-webhook/orca-webhook.gradle index 0cf929e10a..b8e8d4b227 100644 --- a/orca-webhook/orca-webhook.gradle +++ b/orca-webhook/orca-webhook.gradle @@ -25,10 +25,19 @@ dependencies { implementation("org.springframework.boot:spring-boot-autoconfigure") compileOnly("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.mockito:mockito-core") + testImplementation("org.spockframework:spock-core") + testImplementation("org.spockframework:spock-spring") 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/groovy/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationSpec.groovy b/orca-webhook/src/test/groovy/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationSpec.groovy new file mode 100644 index 0000000000..37b2e40280 --- /dev/null +++ b/orca-webhook/src/test/groovy/com/netflix/spinnaker/orca/webhook/config/MtlsConfigurationSpec.groovy @@ -0,0 +1,230 @@ +/* + * 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.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 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.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 +import spock.lang.Specification + +import javax.net.ssl.X509TrustManager +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import java.security.cert.Certificate +import java.security.cert.X509Certificate +import java.security.spec.RSAKeyGenParameterSpec +import java.time.Duration + +@SpringBootTest(classes = [WebhookConfiguration, TestConfiguration, WebhookService, OkHttpClientComponents, OkHttp3ClientConfiguration]) +class MtlsConfigurationSpec extends Specification { + + private static char[] password = "password".toCharArray() + + // Tempfiles to store our keystores + private static File caStoreFile = File.createTempFile("testca", "") + private static File clientIdentityStoreFile = File.createTempFile("testid", "") + + private static MockWebServer mockWebServer + private static ObjectMapper mapper = new 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 + def props = new WebhookProperties() + + def identity = new WebhookProperties.IdentitySettings() + identity.setEnabled(true) + identity.setIdentityStore(clientIdentityStoreFile.getAbsolutePath()) + identity.setIdentityStorePassword(password.toString()) + identity.setIdentityKeyPassword(password.toString()) + props.setIdentity(identity) + + def trust = new WebhookProperties.TrustSettings() + trust.setEnabled(true) + trust.setTrustStore(caStoreFile.getAbsolutePath()) + trust.setTrustStorePassword(password.toString()) + props.setTrust(trust) + + // Tell okhttp to skip hostname verification, since all this is made up + props.setInsecureSkipHostnameVerification(true) + + return props + } + } + + private static KeyPair createKeyPair() { + // Create test keypair + def generator = KeyPairGenerator.getInstance("RSA") + generator.initialize(new RSAKeyGenParameterSpec(2048, RSAKeyGenParameterSpec.F4)) + return generator.generateKeyPair() + } + + private static X509Certificate createCertificate(X500Name subject, PrivateKey privateKey, PublicKey publicKey, boolean isCa) { + // Create certificate + def issuer = new X500Name("CN=ca") + def serial = BigInteger.valueOf(System.currentTimeMillis()) + def notBefore = new Date() + def notAfter = Date.from(notBefore.toInstant() + Duration.ofDays(1)) + def 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)) + } + + def certificateHolder = certificateHolderBuilder.build( + new JcaContentSignerBuilder("SHA1withRSA").build(privateKey)) + return (X509Certificate) StandardCrypto.getX509CertificateFactory() + .generateCertificate(new ByteArrayInputStream(certificateHolder.getEncoded())) + } + + private static KeyStore writeTrustStore(Certificate certificate, File file) { + def store = StandardCrypto.getPKCS12KeyStore() + store.load(null) + store.setCertificateEntry("ca", certificate) + store.store(file.newOutputStream(), password) + return store + } + + private static KeyStore writeIdentityStore(PrivateKey privateKey, X509Certificate[] certificateChain, File file) { + def store = StandardCrypto.getPKCS12KeyStore() + store.load(null) + store.setKeyEntry("identity", privateKey, password, certificateChain) + store.store(file.newOutputStream(), password) + return store + } + + @Autowired + WebhookService service + + def setupSpec() { + // Invent a CA that will sign our client certificate, and will be trusted by the server + def caKeyPair = createKeyPair() + def caCert = createCertificate(new X500Name("CN=ca"), caKeyPair.getPrivate(), caKeyPair.getPublic(), true) + def caStore = writeTrustStore(caCert, caStoreFile) + + // Create a client identity signed by our invented CA + def clientIdentityKeyPair = createKeyPair() + def 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 + def serverIdentityKeyPair = createKeyPair() + def serverIdentityCert = createCertificate(new X500Name("CN=server"), caKeyPair.getPrivate(), serverIdentityKeyPair.getPublic(), false) + + // Set server trust to our CA we just made + def serverTrustManagerFactory = StandardCrypto.getPKIXTrustManagerFactory() + serverTrustManagerFactory.init(caStore) + def serverTrustManager = (X509TrustManager) serverTrustManagerFactory.getTrustManagers()[0] + + // Set the server identity to the server identity we just made + def serverIdentity = new StaticX509Identity(serverIdentityKeyPair.getPrivate(), serverIdentityCert) + def 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() + } + + def cleanupSpec() { + mockWebServer.shutdown() + } + + def "connects with mTLS when identity is configured"() { + given: + def stageExecution = new StageExecutionImpl(null, null, null, [ + 'url': mockWebServer.url("/").toString(), + 'method': HttpMethod.POST, + 'payload': '{ "foo": "bar" }' + ]) + + when: + def response = service.callWebhook(stageExecution) + + then: + response.getStatusCode() == HttpStatus.OK + def body = mapper.readValue(response.getBody().toString(), Map.class) + body["mtls"] == "yep" + } +}