Skip to content

Commit

Permalink
feat(webhook): Single-identity mTLS webhook configuration
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jcavanagh committed Oct 4, 2024
1 parent 9f13be1 commit 24baa3d
Show file tree
Hide file tree
Showing 4 changed files with 381 additions and 23 deletions.
9 changes: 9 additions & 0 deletions orca-webhook/orca-webhook.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.*;
Expand All @@ -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;
Expand All @@ -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<HttpMessageConverter<?>> converters = restTemplate.getMessageConverters();
var converters = restTemplate.getMessageConverters();
converters.add(new ObjectStringHttpMessageConverter());
converters.add(new MapToStringHttpMessageConverter());
restTemplate.setMessageConverters(converters);
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -116,38 +124,83 @@ public ClientHttpRequestFactory webhookRequestFactory(
}

private X509TrustManager webhookX509TrustManager() {
List<X509TrustManager> trustManagers = new ArrayList<>();
var trustManagers = new ArrayList<X509TrustManager>();

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);
}
}

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<KeyStore> getCustomKeyStore() {
WebhookProperties.TrustSettings trustSettings = webhookProperties.getTrust();
private Optional<X509Identity> 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<KeyStore> getCustomTrustStore() {
var trustSettings = webhookProperties.getTrust();
if (trustSettings == null
|| !trustSettings.isEnabled()
|| StringUtils.isEmpty(trustSettings.getTrustStore())) {
Expand All @@ -156,7 +209,7 @@ private Optional<KeyStore> getCustomKeyStore() {

KeyStore keyStore;
try {
keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore = KeyStore.getInstance(trustSettings.getTrustStoreType());
} catch (KeyStoreException e) {
throw new RuntimeException(e);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,34 @@ public class WebhookProperties {
.collect(Collectors.toList());

private List<PreconfiguredWebhook> preconfigured = new ArrayList<>();
private TrustSettings trust;
private TrustSettings trust = new TrustSettings();
private IdentitySettings identity = new IdentitySettings();

private boolean verifyRedirects = true;

private List<Integer> 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
Expand Down
Loading

0 comments on commit 24baa3d

Please sign in to comment.