Skip to content

Commit e0a21cc

Browse files
Add ability to configure a trust store for self-signed Zulip server certificates
Fixes #445
1 parent d511e11 commit e0a21cc

4 files changed

Lines changed: 167 additions & 0 deletions

File tree

src/main/java/com/github/jamesnetherton/zulip/client/http/ZulipConfiguration.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
public class ZulipConfiguration {
1717

1818
private String apiKey;
19+
private String certBundle;
1920
private String email;
2021
private boolean insecure;
2122
private URL proxyUrl;
@@ -65,6 +66,19 @@ public void setApiKey(String apiKey) {
6566
this.apiKey = apiKey;
6667
}
6768

69+
/**
70+
* The path to a PEM-format CA certificate bundle for connecting to a Zulip server that uses a self-signed certificate.
71+
*
72+
* @param certBundle The path to the PEM-format CA certificate bundle
73+
*/
74+
public void setCertBundle(String certBundle) {
75+
this.certBundle = certBundle;
76+
}
77+
78+
public String getCertBundle() {
79+
return certBundle;
80+
}
81+
6882
/**
6983
* Sets the email address to use for authenticating with the Zulip server
7084
*
@@ -201,6 +215,7 @@ public static ZulipConfiguration fromZuliprc(File zulipRcFile) {
201215
String email = (String) zulipProperties.get("email");
202216
String site = (String) zulipProperties.get("site");
203217
String insecureProperty = (String) zulipProperties.get("insecure");
218+
String certBundleProperty = (String) zulipProperties.get("cert_bundle");
204219

205220
if (email == null) {
206221
throw new IllegalArgumentException("email property is not present in zuliprc");
@@ -225,6 +240,9 @@ public static ZulipConfiguration fromZuliprc(File zulipRcFile) {
225240
configuration.setApiKey(key);
226241
configuration.setZulipUrl(ZulipUrlUtils.getZulipApiUrl(site));
227242
configuration.setInsecure(insecure);
243+
if (certBundleProperty != null) {
244+
configuration.setCertBundle(certBundleProperty);
245+
}
228246
return configuration;
229247
} catch (MalformedURLException e) {
230248
throw new IllegalArgumentException("Site must be a valid URL");

src/main/java/com/github/jamesnetherton/zulip/client/http/commons/ZulipCommonsHttpClient.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,23 @@
88
import com.github.jamesnetherton.zulip.client.util.JsonUtils;
99
import com.github.jamesnetherton.zulip.client.util.ZulipUrlUtils;
1010
import java.io.File;
11+
import java.io.FileInputStream;
1112
import java.io.IOException;
13+
import java.io.InputStream;
1214
import java.net.URI;
1315
import java.net.URISyntaxException;
1416
import java.net.URL;
1517
import java.nio.charset.StandardCharsets;
1618
import java.nio.file.Files;
1719
import java.security.KeyManagementException;
20+
import java.security.KeyStore;
1821
import java.security.KeyStoreException;
1922
import java.security.NoSuchAlgorithmException;
23+
import java.security.cert.Certificate;
24+
import java.security.cert.CertificateException;
25+
import java.security.cert.CertificateFactory;
2026
import java.util.ArrayList;
27+
import java.util.Collection;
2128
import java.util.List;
2229
import java.util.Map;
2330
import java.util.concurrent.TimeUnit;
@@ -144,6 +151,33 @@ public void configure() throws ZulipClientException {
144151
} catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
145152
throw new ZulipClientException(e);
146153
}
154+
} else {
155+
String certBundle = configuration.getCertBundle();
156+
if (certBundle != null && !certBundle.isEmpty()) {
157+
try (InputStream is = new FileInputStream(certBundle)) {
158+
CertificateFactory cf = CertificateFactory.getInstance("X.509");
159+
Collection<? extends Certificate> certs = cf.generateCertificates(is);
160+
KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
161+
ks.load(null, null);
162+
int i = 0;
163+
for (Certificate cert : certs) {
164+
ks.setCertificateEntry("cert" + i++, cert);
165+
}
166+
SSLContext sslContext = new SSLContextBuilder()
167+
.loadTrustMaterial(ks, null)
168+
.build();
169+
SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext,
170+
NoopHostnameVerifier.INSTANCE);
171+
HttpClientConnectionManager connectionManager = PoolingHttpClientConnectionManagerBuilder
172+
.create()
173+
.setSSLSocketFactory(sslConnectionSocketFactory)
174+
.build();
175+
builder.setConnectionManager(connectionManager);
176+
} catch (CertificateException | IOException | NoSuchAlgorithmException | KeyStoreException
177+
| KeyManagementException e) {
178+
throw new ZulipClientException(e);
179+
}
180+
}
147181
}
148182

149183
this.client = builder.useSystemProperties().build();

src/test/java/com/github/jamesnetherton/zulip/client/http/ZulipConfigurationTest.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
44
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
5+
import static org.junit.jupiter.api.Assertions.assertNull;
56
import static org.junit.jupiter.api.Assertions.assertThrows;
67
import static org.junit.jupiter.api.Assertions.assertTrue;
78

@@ -81,6 +82,20 @@ public void invalidFile() {
8182
assertThrows(IllegalArgumentException.class, () -> ZulipConfiguration.fromZuliprc(zuliprc));
8283
}
8384

85+
@Test
86+
public void certBundle() throws IOException {
87+
File zuliprc = createZuliprc(EMAIL, KEY, SITE, "/path/to/cert.pem");
88+
ZulipConfiguration configuration = ZulipConfiguration.fromZuliprc(zuliprc);
89+
assertEquals("/path/to/cert.pem", configuration.getCertBundle());
90+
}
91+
92+
@Test
93+
public void certBundleNotSet() throws IOException {
94+
File zuliprc = createZuliprc(EMAIL, KEY, SITE);
95+
ZulipConfiguration configuration = ZulipConfiguration.fromZuliprc(zuliprc);
96+
assertNull(configuration.getCertBundle());
97+
}
98+
8499
@Test
85100
public void invalidHttpClientFactory() throws MalformedURLException {
86101
ZulipConfiguration configuration = new ZulipConfiguration(ZulipUrlUtils.getZulipApiUrl(SITE), KEY, EMAIL);
@@ -101,6 +116,10 @@ public void nullUserHome() {
101116
}
102117

103118
private File createZuliprc(String email, String key, String site) throws IOException {
119+
return createZuliprc(email, key, site, null);
120+
}
121+
122+
private File createZuliprc(String email, String key, String site, String certBundle) throws IOException {
104123
Properties properties = new Properties();
105124

106125
if (email != null) {
@@ -115,6 +134,10 @@ private File createZuliprc(String email, String key, String site) throws IOExcep
115134
properties.setProperty("site", site);
116135
}
117136

137+
if (certBundle != null) {
138+
properties.setProperty("cert_bundle", certBundle);
139+
}
140+
118141
properties.setProperty("insecure", "true");
119142

120143
Path path = Paths.get(System.getProperty("java.io.tmpdir"), "zuliprc");

src/test/java/com/github/jamesnetherton/zulip/client/http/commons/ZulipCommonsHttpClientTest.java

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
55
import static com.github.tomakehurst.wiremock.client.WireMock.request;
66
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
7+
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options;
78
import static org.junit.jupiter.api.Assertions.assertEquals;
89
import static org.junit.jupiter.api.Assertions.assertNotNull;
910
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -15,20 +16,31 @@
1516
import com.github.jamesnetherton.zulip.client.exception.ZulipClientException;
1617
import com.github.jamesnetherton.zulip.client.exception.ZulipRateLimitExceededException;
1718
import com.github.jamesnetherton.zulip.client.http.ZulipConfiguration;
19+
import com.github.tomakehurst.wiremock.WireMockServer;
1820
import java.io.BufferedReader;
21+
import java.io.File;
22+
import java.io.FileWriter;
1923
import java.io.IOException;
2024
import java.io.InputStreamReader;
2125
import java.io.OutputStream;
2226
import java.net.ServerSocket;
2327
import java.net.Socket;
2428
import java.net.URL;
2529
import java.nio.charset.StandardCharsets;
30+
import java.security.SecureRandom;
31+
import java.security.cert.Certificate;
32+
import java.security.cert.X509Certificate;
33+
import java.util.Base64;
2634
import java.util.Collections;
2735
import java.util.List;
2836
import java.util.concurrent.CountDownLatch;
2937
import java.util.concurrent.ExecutorService;
3038
import java.util.concurrent.Executors;
3139
import java.util.concurrent.TimeUnit;
40+
import javax.net.ssl.SSLContext;
41+
import javax.net.ssl.SSLSocket;
42+
import javax.net.ssl.TrustManager;
43+
import javax.net.ssl.X509TrustManager;
3244
import org.junit.jupiter.api.Test;
3345

3446
public class ZulipCommonsHttpClientTest extends ZulipApiTestBase {
@@ -135,6 +147,86 @@ public void ignoredParameters() throws Exception {
135147
}
136148
}
137149

150+
@Test
151+
public void certBundle() throws Exception {
152+
WireMockServer httpsServer = new WireMockServer(options().dynamicHttpsPort());
153+
httpsServer.start();
154+
155+
try {
156+
X509Certificate cert = getServerCertificate("localhost", httpsServer.httpsPort());
157+
File certFile = writeCertToPem(cert);
158+
159+
httpsServer.stubFor(request("GET", urlPathEqualTo("/api/v1/messages"))
160+
.willReturn(aResponse()
161+
.withStatus(200)
162+
.withBody("{\"result\":\"success\",\"msg\":\"\"}")));
163+
164+
ZulipConfiguration configuration = new ZulipConfiguration(
165+
new URL("https://localhost:" + httpsServer.httpsPort()), "test@test.com", "abc123");
166+
configuration.setCertBundle(certFile.getAbsolutePath());
167+
168+
ZulipCommonsHttpClient client = new ZulipCommonsHttpClient(configuration);
169+
ZulipApiResponse response = client.get("messages", Collections.emptyMap(), ZulipApiResponse.class);
170+
assertNotNull(response);
171+
} finally {
172+
httpsServer.stop();
173+
}
174+
}
175+
176+
@Test
177+
public void certBundleWithUntrustedServer() throws Exception {
178+
WireMockServer httpsServer = new WireMockServer(options().dynamicHttpsPort());
179+
httpsServer.start();
180+
181+
try {
182+
File emptyCertFile = File.createTempFile("empty-cert", ".pem");
183+
emptyCertFile.deleteOnExit();
184+
185+
ZulipConfiguration configuration = new ZulipConfiguration(
186+
new URL("https://localhost:" + httpsServer.httpsPort()), "test@test.com", "abc123");
187+
configuration.setCertBundle(emptyCertFile.getAbsolutePath());
188+
189+
ZulipCommonsHttpClient client = new ZulipCommonsHttpClient(configuration);
190+
assertThrows(ZulipClientException.class, () -> {
191+
client.get("messages", Collections.emptyMap(), ZulipApiResponse.class);
192+
});
193+
} finally {
194+
httpsServer.stop();
195+
}
196+
}
197+
198+
private X509Certificate getServerCertificate(String host, int port) throws Exception {
199+
SSLContext sslContext = SSLContext.getInstance("TLS");
200+
sslContext.init(null, new TrustManager[] { new X509TrustManager() {
201+
public X509Certificate[] getAcceptedIssuers() {
202+
return new X509Certificate[0];
203+
}
204+
205+
public void checkClientTrusted(X509Certificate[] certs, String authType) {
206+
}
207+
208+
public void checkServerTrusted(X509Certificate[] certs, String authType) {
209+
}
210+
} }, new SecureRandom());
211+
212+
try (SSLSocket socket = (SSLSocket) sslContext.getSocketFactory().createSocket(host, port)) {
213+
socket.startHandshake();
214+
Certificate[] certs = socket.getSession().getPeerCertificates();
215+
return (X509Certificate) certs[0];
216+
}
217+
}
218+
219+
private File writeCertToPem(X509Certificate cert) throws Exception {
220+
File certFile = File.createTempFile("test-cert", ".pem");
221+
certFile.deleteOnExit();
222+
try (FileWriter fw = new FileWriter(certFile)) {
223+
fw.write("-----BEGIN CERTIFICATE-----\n");
224+
fw.write(Base64.getMimeEncoder(64, new byte[] { '\n' }).encodeToString(cert.getEncoded()));
225+
fw.write("\n-----END CERTIFICATE-----\n");
226+
}
227+
return certFile;
228+
}
229+
138230
private class FakeServer {
139231
private final ServerSocket serverSocket;
140232
private final ExecutorService executor;

0 commit comments

Comments
 (0)