Skip to content

Commit

Permalink
FFM-7004 - Java SDK - TLS - support custom CAs (#138)
Browse files Browse the repository at this point in the history
* FFM-7004 - Java SDK - TLS - support custom CAs

What
Allow the SDK user to provide a custom TLS CA via the HarnessConnector API

Why
We currently rely on pre-installed CA bundles in the JDK for TLS connections, e.g. those hostnames signed by public CAs, it's useful to provide alternative methods of loading private TLS CAs for internal on-prem deployments

Testing
Manual - tested against a ff-server proxy with TLS termination enabled
  • Loading branch information
andybharness authored Mar 7, 2023
1 parent 588e42e commit e7a1ce1
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 18 deletions.
11 changes: 9 additions & 2 deletions examples/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.harness.featureflags</groupId>
<artifactId>examples</artifactId>
<version>1.1.11</version>
<version>1.2.0</version>

<properties>
<maven.compiler.source>8</maven.compiler.source>
Expand All @@ -33,7 +33,7 @@
<dependency>
<groupId>io.harness</groupId>
<artifactId>ff-java-server-sdk</artifactId>
<version>1.1.11</version>
<version>1.2.0</version>
</dependency>

<dependency>
Expand Down Expand Up @@ -65,6 +65,13 @@
<version>2.19.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.bouncycastle/bcpkix-jdk18on -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>1.72</version>
</dependency>

</dependencies>


Expand Down
100 changes: 100 additions & 0 deletions examples/src/main/java/io/harness/ff/examples/TlsExample.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package io.harness.ff.examples;

import io.harness.cf.client.api.BaseConfig;
import io.harness.cf.client.api.CfClient;
import io.harness.cf.client.api.FeatureFlagInitializeException;
import io.harness.cf.client.connector.HarnessConfig;
import io.harness.cf.client.connector.HarnessConnector;
import io.harness.cf.client.dto.Target;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.openssl.PEMParser;

import java.io.FileReader;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.Provider;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import static java.lang.System.out;

public class TlsExample {
private static final String apiKey = getEnvOrDefault("FF_API_KEY", "");
private static final String flagName = getEnvOrDefault("FF_FLAG_NAME", "harnessappdemodarkmode");
private static final String trustedCaPemFile = getEnvOrDefault("FF_TRUSTED_CA_FILE_NAME", "/change/me/CA.pem");

private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private static final Provider bcProvider = new BouncyCastleProvider();


public static void main(String[] args) throws InterruptedException, FeatureFlagInitializeException, GeneralSecurityException, IOException {
out.println("Java SDK TLS example");

List<X509Certificate> trustedServers = loadCerts(trustedCaPemFile);

// Note that this code uses ffserver hostname as an example, likely you'll have your own hostname or IP.
// You should ensure the endpoint is returning a cert with valid SANs configured for the host/IP.
HarnessConfig config = HarnessConfig.builder()
.configUrl("https://ffserver:8001/api/1.0")
.eventUrl("https://ffserver:8000/api/1.0")
.tlsTrustedCAs(trustedServers)
.build();

HarnessConnector connector = new HarnessConnector(apiKey, config);

try (CfClient cfClient = new CfClient(connector)) {

cfClient.waitForInitialization();

final Target target = Target.builder()
.identifier("javasdk")
.name("JavaSDK")
.build();

// Loop forever reporting the state of the flag
scheduler.scheduleAtFixedRate(
() -> {
boolean result = cfClient.boolVariation(flagName, target, false);
out.println("Flag '" + flagName + "' Boolean variation is " + result);
},
0,
10,
TimeUnit.SECONDS);


TimeUnit.MINUTES.sleep(15);

out.println("Cleaning up...");
scheduler.shutdownNow();
}
}

// Get the value from the environment or return the default
private static String getEnvOrDefault(String key, String defaultValue) {
String value = System.getenv(key);
if (value == null || value.isEmpty()) {
return defaultValue;
}
return value;
}

// Here we're using BC's PKIX lib to convert the PEM to an X.509, you can use any crypto library you prefer
private static List<X509Certificate> loadCerts(String filename) throws IOException, CertificateException {
List<X509Certificate> list = new ArrayList<>();
try (PEMParser parser = new PEMParser(new FileReader(filename))) {
Object obj;
while ((obj = parser.readObject()) != null) {
if (obj instanceof X509CertificateHolder) {
list.add(new JcaX509CertificateConverter().setProvider(bcProvider).getCertificate((X509CertificateHolder) obj));
}
}
}
return list;
}
}
4 changes: 2 additions & 2 deletions examples/src/main/resources/log4j.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
log4j.rootLogger=debug
log4j.logger.io.harness=debug
log4j.rootLogger=info
log4j.logger.io.harness=info
2 changes: 1 addition & 1 deletion examples/src/main/resources/log4j2.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</Appenders>

<Loggers>
<Root level="debug">
<Root level="info">
<AppenderRef ref="console"/>
</Root>
</Loggers>
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.harness</groupId>
<artifactId>ff-java-server-sdk</artifactId>
<version>1.1.11</version>
<version>1.2.0</version>
<packaging>jar</packaging>
<name>Harness Feature Flag Java Server SDK</name>
<description>Harness Feature Flag Java Server SDK</description>
Expand Down
56 changes: 51 additions & 5 deletions src/main/java/io/harness/cf/client/connector/EventSource.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@
import com.here.oksse.ServerSentEvent;
import io.harness.cf.client.dto.Message;
import io.harness.cf.client.logger.LogUtil;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.security.cert.X509Certificate;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import javax.net.ssl.*;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -36,31 +43,38 @@ public EventSource(
@NonNull String url,
Map<String, String> headers,
@NonNull Updater updater,
long sseReadTimeoutMins) {
this(url, headers, updater, sseReadTimeoutMins, 2_000);
long sseReadTimeoutMins)
throws ConnectorException {
this(url, headers, updater, sseReadTimeoutMins, 2_000, null);
}

EventSource(
@NonNull String url,
Map<String, String> headers,
@NonNull Updater updater,
long sseReadTimeoutMins,
int retryDelayMs) {
int retryDelayMs,
List<X509Certificate> trustedCAs)
throws ConnectorException {
this.updater = updater;
this.retryTime = retryDelayMs;
okSse = new OkSse(makeStreamClient(sseReadTimeoutMins));
okSse = new OkSse(makeStreamClient(sseReadTimeoutMins, trustedCAs));
builder = new Request.Builder().url(url);
headers.put("User-Agent", "JavaSDK " + io.harness.cf.Version.VERSION);
headers.forEach(builder::header);
updater.onReady();
log.info("EventSource initialized with url {} and headers {}", url, headers);
}

protected OkHttpClient makeStreamClient(long sseReadTimeoutMins) {
protected OkHttpClient makeStreamClient(long sseReadTimeoutMins, List<X509Certificate> trustedCAs)
throws ConnectorException {
OkHttpClient.Builder httpClientBuilder =
new OkHttpClient.Builder()
.readTimeout(sseReadTimeoutMins, TimeUnit.MINUTES)
.retryOnConnectionFailure(true);

setupTls(httpClientBuilder, trustedCAs);

if (log.isDebugEnabled()) {
loggingInterceptor = new HttpLoggingInterceptor();
loggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
Expand All @@ -85,6 +99,38 @@ protected OkHttpClient makeStreamClient(long sseReadTimeoutMins) {
return httpClientBuilder.build();
}

public boolean throwex = true;

private void setupTls(OkHttpClient.Builder httpClientBuilder, List<X509Certificate> trustedCAs)
throws ConnectorException {

try {
if (trustedCAs != null && !trustedCAs.isEmpty()) {

final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
for (int i = 0; i < trustedCAs.size(); i++) {
keyStore.setCertificateEntry("ca" + i, trustedCAs.get(i));
}

final TrustManagerFactory trustManagerFactory =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init(keyStore);
final TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();

final SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustManagers, new SecureRandom());

httpClientBuilder.sslSocketFactory(
sslContext.getSocketFactory(), (X509TrustManager) trustManagers[0]);
}
} catch (GeneralSecurityException | IOException ex) {
String msg = "Failed to setup TLS on SSE endpoint: " + ex.getMessage();
log.warn(msg, ex);
throw new ConnectorException(msg, true, ex);
}
}

@Override
public void onOpen(ServerSentEvent serverSentEvent, Response response) {
log.info("EventSource onOpen");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package io.harness.cf.client.connector;

import java.security.cert.X509Certificate;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -25,4 +27,10 @@ public class HarnessConfig {

/** read timeout in minutes for SSE connections */
@Builder.Default long sseReadTimeout = 1;

/**
* list of trusted CAs - for when the given config/event URLs are signed with a private CA. You
* should include intermediate CAs too to allow the HTTP client to build a full trust chain.
*/
@Builder.Default List<X509Certificate> tlsTrustedCAs = null;
}
41 changes: 38 additions & 3 deletions src/main/java/io/harness/cf/client/connector/HarnessConnector.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import io.harness.cf.client.dto.Claim;
import io.harness.cf.client.logger.LogUtil;
import io.harness.cf.model.*;
import java.io.IOException;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.util.*;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import okhttp3.Interceptor;
import okhttp3.Request;
Expand Down Expand Up @@ -51,6 +53,11 @@ public HarnessConnector(@NonNull final String apiKey, @NonNull final HarnessConf
log.info("Connector initialized, with options " + options);
}

@SneakyThrows
private byte[] certToByteArray(X509Certificate cert) {
return cert.getEncoded();
}

ApiClient makeApiClient(int retryBackOfDelay) {
final ApiClient apiClient = new ApiClient();
apiClient.setBasePath(options.getConfigUrl());
Expand All @@ -59,6 +66,9 @@ ApiClient makeApiClient(int retryBackOfDelay) {
apiClient.setWriteTimeout(options.getWriteTimeout());
apiClient.setDebugging(log.isDebugEnabled());
apiClient.setUserAgent("JavaSDK " + io.harness.cf.Version.VERSION);

setupTls(apiClient);

// if http client response is 403 we need to reauthenticate
apiClient.setHttpClient(
apiClient
Expand Down Expand Up @@ -94,6 +104,9 @@ ApiClient makeMetricsApiClient(int retryBackoffDelay) {
apiClient.setWriteTimeout(maxTimeout);
apiClient.setDebugging(log.isDebugEnabled());
apiClient.setUserAgent("JavaSDK " + io.harness.cf.Version.VERSION);

setupTls(apiClient);

apiClient.setHttpClient(
apiClient
.getHttpClient()
Expand Down Expand Up @@ -313,7 +326,7 @@ public void postMetrics(@NonNull final Metrics metrics) throws ConnectorExceptio
}

@Override
public Service stream(@NonNull final Updater updater) {
public Service stream(@NonNull final Updater updater) throws ConnectorException {
log.debug("Check if eventsource is already initialized");
if (eventSource != null) {
log.debug("EventSource is already initialized, closing ...");
Expand All @@ -325,7 +338,14 @@ public Service stream(@NonNull final Updater updater) {
map.put("Authorization", "Bearer " + token);
map.put("API-Key", apiKey);
log.info("Initialize new EventSource instance");
eventSource = new EventSource(sseUrl, map, updater, Math.max(options.getSseReadTimeout(), 1));
eventSource =
new EventSource(
sseUrl,
map,
updater,
Math.max(options.getSseReadTimeout(), 1),
2_000,
options.getTlsTrustedCAs());
return eventSource;
}

Expand All @@ -342,6 +362,21 @@ public void close() {
log.debug("connector closed!");
}

private void setupTls(ApiClient apiClient) {
final List<X509Certificate> trustedCAs = options.getTlsTrustedCAs();
if (trustedCAs != null && !trustedCAs.isEmpty()) {

// because openapi doesn't take X509 certs directly we need some boilerplate
byte[] certsAsBytes =
trustedCAs.stream()
.map(this::certToByteArray)
.collect(ByteArrayOutputStream::new, (s, b) -> s.write(b, 0, b.length), (a, b) -> {})
.toByteArray();

apiClient.setSslCaCert(new ByteArrayInputStream(certsAsBytes));
}
}

/* package private - should not be used outside of tests */

HarnessConnector(
Expand Down
Loading

0 comments on commit e7a1ce1

Please sign in to comment.