Skip to content

Commit 24baa3d

Browse files
committed
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.
1 parent 9f13be1 commit 24baa3d

File tree

4 files changed

+381
-23
lines changed

4 files changed

+381
-23
lines changed

orca-webhook/orca-webhook.gradle

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,20 @@ dependencies {
2424
implementation("io.spinnaker.kork:kork-web")
2525
implementation("org.springframework.boot:spring-boot-autoconfigure")
2626
compileOnly("org.projectlombok:lombok")
27+
testCompileOnly("org.projectlombok:lombok")
2728
annotationProcessor("org.projectlombok:lombok")
29+
testAnnotationProcessor("org.projectlombok:lombok")
2830
implementation("com.jayway.jsonpath:json-path")
2931
implementation("com.squareup.okhttp3:okhttp")
3032
implementation("com.jakewharton.retrofit:retrofit1-okhttp3-client:1.1.0")
33+
34+
testImplementation("com.squareup.okhttp3:mockwebserver")
35+
testImplementation("io.spinnaker.kork:kork-test")
36+
testImplementation("org.bouncycastle:bcpkix-jdk18on")
37+
testImplementation("org.junit.jupiter:junit-jupiter-api")
38+
testImplementation("org.mockito:mockito-core")
3139
testImplementation("org.springframework:spring-test")
40+
testImplementation("org.springframework.boot:spring-boot-test")
3241
testImplementation("org.apache.groovy:groovy-json")
3342
testRuntimeOnly("net.bytebuddy:byte-buddy")
3443

orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookConfiguration.java

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717

1818
package com.netflix.spinnaker.orca.webhook.config;
1919

20+
import com.netflix.spinnaker.kork.crypto.X509Identity;
21+
import com.netflix.spinnaker.kork.crypto.X509IdentitySource;
2022
import com.netflix.spinnaker.okhttp.OkHttpClientConfigurationProperties;
2123
import com.netflix.spinnaker.orca.config.UserConfiguredUrlRestrictions;
2224
import com.netflix.spinnaker.orca.webhook.util.UnionX509TrustManager;
@@ -25,13 +27,14 @@
2527
import java.io.UnsupportedEncodingException;
2628
import java.net.URLEncoder;
2729
import java.nio.charset.Charset;
30+
import java.nio.file.Path;
2831
import java.security.KeyManagementException;
2932
import java.security.KeyStore;
3033
import java.security.KeyStoreException;
3134
import java.security.NoSuchAlgorithmException;
3235
import java.security.cert.CertificateException;
36+
import java.security.cert.X509Certificate;
3337
import java.util.ArrayList;
34-
import java.util.List;
3538
import java.util.Map;
3639
import java.util.Optional;
3740
import javax.net.ssl.*;
@@ -51,7 +54,6 @@
5154
import org.springframework.http.client.ClientHttpRequestFactory;
5255
import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
5356
import org.springframework.http.converter.AbstractHttpMessageConverter;
54-
import org.springframework.http.converter.HttpMessageConverter;
5557
import org.springframework.http.converter.HttpMessageNotReadableException;
5658
import org.springframework.http.converter.HttpMessageNotWritableException;
5759
import org.springframework.http.converter.StringHttpMessageConverter;
@@ -73,9 +75,9 @@ public WebhookConfiguration(WebhookProperties webhookProperties) {
7375
@Bean
7476
@ConditionalOnMissingBean(RestTemplate.class)
7577
public RestTemplate restTemplate(ClientHttpRequestFactory webhookRequestFactory) {
76-
RestTemplate restTemplate = new RestTemplate(webhookRequestFactory);
78+
var restTemplate = new RestTemplate(webhookRequestFactory);
7779

78-
List<HttpMessageConverter<?>> converters = restTemplate.getMessageConverters();
80+
var converters = restTemplate.getMessageConverters();
7981
converters.add(new ObjectStringHttpMessageConverter());
8082
converters.add(new MapToStringHttpMessageConverter());
8183
restTemplate.setMessageConverters(converters);
@@ -86,10 +88,11 @@ public RestTemplate restTemplate(ClientHttpRequestFactory webhookRequestFactory)
8688
@Bean
8789
public ClientHttpRequestFactory webhookRequestFactory(
8890
OkHttpClientConfigurationProperties okHttpClientConfigurationProperties,
89-
UserConfiguredUrlRestrictions userConfiguredUrlRestrictions) {
90-
X509TrustManager trustManager = webhookX509TrustManager();
91-
SSLSocketFactory sslSocketFactory = getSSLSocketFactory(trustManager);
92-
OkHttpClient client =
91+
UserConfiguredUrlRestrictions userConfiguredUrlRestrictions)
92+
throws IOException {
93+
var trustManager = webhookX509TrustManager();
94+
var sslSocketFactory = getSSLSocketFactory(trustManager);
95+
var builder =
9396
new OkHttpClient.Builder()
9497
.sslSocketFactory(sslSocketFactory, trustManager)
9598
.addNetworkInterceptor(
@@ -105,9 +108,14 @@ public ClientHttpRequestFactory webhookRequestFactory(
105108
}
106109

107110
return response;
108-
})
109-
.build();
110-
OkHttp3ClientHttpRequestFactory requestFactory = new OkHttp3ClientHttpRequestFactory(client);
111+
});
112+
113+
if (webhookProperties.isInsecureSkipHostnameVerification()) {
114+
builder.hostnameVerifier((hostname, session) -> true);
115+
}
116+
117+
var client = builder.build();
118+
var requestFactory = new OkHttp3ClientHttpRequestFactory(client);
111119
requestFactory.setReadTimeout(
112120
Math.toIntExact(okHttpClientConfigurationProperties.getReadTimeoutMs()));
113121
requestFactory.setConnectTimeout(
@@ -116,38 +124,83 @@ public ClientHttpRequestFactory webhookRequestFactory(
116124
}
117125

118126
private X509TrustManager webhookX509TrustManager() {
119-
List<X509TrustManager> trustManagers = new ArrayList<>();
127+
var trustManagers = new ArrayList<X509TrustManager>();
120128

121129
trustManagers.add(getTrustManager(null));
122-
getCustomKeyStore().ifPresent(keyStore -> trustManagers.add(getTrustManager(keyStore)));
130+
getCustomTrustStore().ifPresent(keyStore -> trustManagers.add(getTrustManager(keyStore)));
131+
132+
if (webhookProperties.isInsecureTrustSelfSigned()) {
133+
trustManagers.add(
134+
new X509TrustManager() {
135+
@Override
136+
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
137+
138+
@Override
139+
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
140+
141+
@Override
142+
public X509Certificate[] getAcceptedIssuers() {
143+
return new X509Certificate[0];
144+
}
145+
});
146+
}
123147

124148
return new UnionX509TrustManager(trustManagers);
125149
}
126150

127-
private SSLSocketFactory getSSLSocketFactory(X509TrustManager trustManager) {
151+
private SSLSocketFactory getSSLSocketFactory(X509TrustManager trustManager) throws IOException {
128152
try {
129-
SSLContext sslContext = SSLContext.getInstance("TLS");
130-
sslContext.init(null, new X509TrustManager[] {trustManager}, null);
131-
return sslContext.getSocketFactory();
153+
var identityOpt = getCustomIdentity();
154+
if (identityOpt.isPresent()) {
155+
var identity = identityOpt.get();
156+
return identity.createSSLContext(trustManager).getSocketFactory();
157+
} else {
158+
var sslContext = SSLContext.getInstance("TLS");
159+
sslContext.init(null, new X509TrustManager[] {trustManager}, null);
160+
return sslContext.getSocketFactory();
161+
}
132162
} catch (KeyManagementException | NoSuchAlgorithmException e) {
133163
throw new RuntimeException(e);
134164
}
135165
}
136166

137167
private X509TrustManager getTrustManager(KeyStore keyStore) {
138168
try {
139-
TrustManagerFactory trustManagerFactory =
169+
var trustManagerFactory =
140170
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
141171
trustManagerFactory.init(keyStore);
142-
TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
172+
var trustManagers = trustManagerFactory.getTrustManagers();
143173
return (X509TrustManager) trustManagers[0];
144174
} catch (KeyStoreException | NoSuchAlgorithmException e) {
145175
throw new RuntimeException(e);
146176
}
147177
}
148178

149-
private Optional<KeyStore> getCustomKeyStore() {
150-
WebhookProperties.TrustSettings trustSettings = webhookProperties.getTrust();
179+
private Optional<X509Identity> getCustomIdentity() throws IOException {
180+
var identitySettings = webhookProperties.getIdentity();
181+
if (identitySettings == null
182+
|| !identitySettings.isEnabled()
183+
|| StringUtils.isEmpty(identitySettings.getIdentityStore())) {
184+
return Optional.empty();
185+
}
186+
187+
var identity =
188+
X509IdentitySource.fromKeyStore(
189+
Path.of(identitySettings.getIdentityStore()),
190+
identitySettings.getIdentityStoreType(),
191+
() -> {
192+
var password = identitySettings.getIdentityStorePassword();
193+
return password == null ? new char[0] : password.toCharArray();
194+
},
195+
() -> {
196+
var password = identitySettings.getIdentityKeyPassword();
197+
return password == null ? new char[0] : password.toCharArray();
198+
});
199+
return Optional.of(identity.load());
200+
}
201+
202+
private Optional<KeyStore> getCustomTrustStore() {
203+
var trustSettings = webhookProperties.getTrust();
151204
if (trustSettings == null
152205
|| !trustSettings.isEnabled()
153206
|| StringUtils.isEmpty(trustSettings.getTrustStore())) {
@@ -156,7 +209,7 @@ private Optional<KeyStore> getCustomKeyStore() {
156209

157210
KeyStore keyStore;
158211
try {
159-
keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
212+
keyStore = KeyStore.getInstance(trustSettings.getTrustStoreType());
160213
} catch (KeyStoreException e) {
161214
throw new RuntimeException(e);
162215
}

orca-webhook/src/main/java/com/netflix/spinnaker/orca/webhook/config/WebhookProperties.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,34 @@ public class WebhookProperties {
5353
.collect(Collectors.toList());
5454

5555
private List<PreconfiguredWebhook> preconfigured = new ArrayList<>();
56-
private TrustSettings trust;
56+
private TrustSettings trust = new TrustSettings();
57+
private IdentitySettings identity = new IdentitySettings();
5758

5859
private boolean verifyRedirects = true;
5960

6061
private List<Integer> defaultRetryStatusCodes = List.of(429);
6162

63+
// For testing *only*
64+
private boolean insecureSkipHostnameVerification = false;
65+
private boolean insecureTrustSelfSigned = false;
66+
6267
@Data
6368
@NoArgsConstructor
6469
public static class TrustSettings {
6570
private boolean enabled;
6671
private String trustStore;
6772
private String trustStorePassword;
73+
private String trustStoreType = "PKCS12";
74+
}
75+
76+
@Data
77+
@NoArgsConstructor
78+
public static class IdentitySettings {
79+
private boolean enabled;
80+
private String identityStore;
81+
private String identityStorePassword;
82+
private String identityKeyPassword;
83+
private String identityStoreType = "PKCS12";
6884
}
6985

7086
@Data

0 commit comments

Comments
 (0)