Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,9 @@ RUN --mount=type=secret,id=confidence_client_secret \
FROM openfeature-provider-java.test AS openfeature-provider-java.test_e2e

RUN --mount=type=secret,id=confidence_client_secret \
--mount=type=secret,id=confidence_client_encryption_key \
CONFIDENCE_CLIENT_SECRET=$(cat /run/secrets/confidence_client_secret) \
CONFIDENCE_CLIENT_ENCRYPTION_KEY=$(cat /run/secrets/confidence_client_encryption_key) \
make test-e2e

# ==============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.concurrent.atomic.AtomicReference;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -25,8 +28,8 @@ class FlagsAdminStateFetcher implements AccountStateProvider {
"https://confidence-resolver-state-cdn.spotifycdn.com/";

private final String clientSecret;
private final String encryptionKey;
private final HttpClientFactory httpClientFactory;
// ETag for conditional GETs of resolver state
private final AtomicReference<String> etagHolder = new AtomicReference<>();
private final AtomicReference<byte[]> rawResolverStateHolder =
new AtomicReference<>(
Expand All @@ -35,13 +38,11 @@ class FlagsAdminStateFetcher implements AccountStateProvider {
.toByteArray());
private String accountId = "";

public FlagsAdminStateFetcher(String clientSecret, HttpClientFactory httpClientFactory) {
public FlagsAdminStateFetcher(
String clientSecret, HttpClientFactory httpClientFactory, String encryptionKey) {
this.clientSecret = clientSecret;
this.httpClientFactory = httpClientFactory;
}

public AtomicReference<byte[]> rawStateHolder() {
return rawResolverStateHolder;
this.encryptionKey = encryptionKey;
}

@Override
Expand All @@ -64,28 +65,28 @@ public void reload() {
}

private void fetchAndUpdateStateIfChanged() {
// Build CDN URL using SHA256 hash of client secret
final var cdnUrl = CDN_BASE_URL + sha256Hex(clientSecret);
final String hash = sha256Hex(clientSecret);
final var cdnUrl = CDN_BASE_URL + hash + (encryptionKey != null ? ".enc" : "");
try {
final HttpURLConnection conn = httpClientFactory.create(cdnUrl);
final String previousEtag = etagHolder.get();
if (previousEtag != null) {
conn.setRequestProperty("if-none-match", previousEtag);
}
if (conn.getResponseCode() == 304) {
// Not modified
return;
}
final String etag = conn.getHeaderField("etag");
try (final InputStream stream = conn.getInputStream()) {
final byte[] bytes = stream.readAllBytes();
byte[] bytes = stream.readAllBytes();

if (encryptionKey != null) {
bytes = decryptAesGcm(bytes, encryptionKey);
}

// Parse SetResolverStateRequest from CDN response
final var stateRequest =
com.spotify.confidence.sdk.wasm.Messages.SetResolverStateRequest.parseFrom(bytes);
this.accountId = stateRequest.getAccountId();

// Store the state bytes (already in bytes format)
rawResolverStateHolder.set(stateRequest.getState().toByteArray());
etagHolder.set(etag);
}
Expand All @@ -95,6 +96,24 @@ private void fetchAndUpdateStateIfChanged() {
}
}

private static byte[] decryptAesGcm(byte[] data, String hexKey) {
try {
final byte[] key = java.util.HexFormat.of().parseHex(hexKey);
final int nonceLen = 12;
if (data.length < nonceLen) {
throw new IllegalArgumentException("Encrypted state too short (missing nonce)");
}
final var cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.DECRYPT_MODE,
new SecretKeySpec(key, "AES"),
new GCMParameterSpec(128, data, 0, nonceLen));
return cipher.doFinal(data, nonceLen, data.length - nonceLen);
} catch (Exception e) {
throw new RuntimeException("Failed to decrypt resolver state", e);
}
}

private static String sha256Hex(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ public class LocalProviderConfig {
private final HttpClientFactory httpClientFactory;
private final boolean useRemoteMaterializationStore;
private final int resolverPoolSize;
private final String encryptionKey;

public LocalProviderConfig() {
this(null, null);
Expand All @@ -36,11 +37,21 @@ public LocalProviderConfig(
HttpClientFactory httpClientFactory,
boolean useRemoteMaterializationStore,
int resolverPoolSize) {
this(channelFactory, httpClientFactory, useRemoteMaterializationStore, resolverPoolSize, null);
}

private LocalProviderConfig(
ChannelFactory channelFactory,
HttpClientFactory httpClientFactory,
boolean useRemoteMaterializationStore,
int resolverPoolSize,
String encryptionKey) {
this.channelFactory = channelFactory != null ? channelFactory : new DefaultChannelFactory();
this.httpClientFactory =
httpClientFactory != null ? httpClientFactory : new DefaultHttpClientFactory();
this.useRemoteMaterializationStore = useRemoteMaterializationStore;
this.resolverPoolSize = resolverPoolSize > 0 ? resolverPoolSize : DEFAULT_RESOLVER_POOL_SIZE;
this.encryptionKey = encryptionKey;
}

public ChannelFactory getChannelFactory() {
Expand All @@ -63,6 +74,11 @@ public int getResolverPoolSize() {
return resolverPoolSize;
}

/** Returns the hex-encoded AES-256 encryption key, or {@code null} if unset. */
public String getEncryptionKey() {
return encryptionKey;
}

public static Builder builder() {
return new Builder();
}
Expand All @@ -72,6 +88,7 @@ public static class Builder {
private HttpClientFactory httpClientFactory;
private boolean useRemoteMaterializationStore;
private int resolverPoolSize;
private String encryptionKey;

public Builder channelFactory(ChannelFactory channelFactory) {
this.channelFactory = channelFactory;
Expand Down Expand Up @@ -100,9 +117,19 @@ public Builder resolverPoolSize(int resolverPoolSize) {
return this;
}

/** Sets the hex-encoded AES-256 encryption key for decrypting CDN state. */
public Builder encryptionKey(String encryptionKey) {
this.encryptionKey = encryptionKey;
return this;
}

public LocalProviderConfig build() {
return new LocalProviderConfig(
channelFactory, httpClientFactory, useRemoteMaterializationStore, resolverPoolSize);
channelFactory,
httpClientFactory,
useRemoteMaterializationStore,
resolverPoolSize,
encryptionKey);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,14 @@ public OpenFeatureLocalResolveProvider(
LocalProviderConfig config, String clientSecret, MaterializationStore materializationStore) {
this.clientSecret = clientSecret;
this.materializationStore = materializationStore;
this.stateProvider = new FlagsAdminStateFetcher(clientSecret, config.getHttpClientFactory());
if (config.getEncryptionKey() == null) {
log.warn(
"No encryptionKey provided. Falling back to unencrypted state."
+ " An encryption key will be required in an upcoming version.");
}
this.stateProvider =
new FlagsAdminStateFetcher(
clientSecret, config.getHttpClientFactory(), config.getEncryptionKey());
final var wasmFlagLogger = new GrpcWasmFlagLogger(clientSecret, config.getChannelFactory());
this.flagLogger = wasmFlagLogger;
final int numInstances = PooledResolver.getNumInstances(config.getResolverPoolSize());
Expand Down Expand Up @@ -234,7 +241,6 @@ private void scheduleStateRefresh(
if (flagsFetcherExecutor.isShutdown()) {
return;
}

// Use short retry interval (1s) when not initialized, normal interval otherwise
long delaySeconds = initialized ? pollIntervalSeconds : 1;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.spotify.confidence.sdk;

import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;

import dev.openfeature.sdk.*;
import org.junit.jupiter.api.*;

class OpenFeatureLocalResolveProviderEncryptedIT {
private static final String FLAG_CLIENT_SECRET = System.getenv("CONFIDENCE_CLIENT_SECRET");
private static final String ENCRYPTION_KEY = System.getenv("CONFIDENCE_CLIENT_ENCRYPTION_KEY");
private static Client client;

@BeforeAll
static void setup() {
final var provider =
new OpenFeatureLocalResolveProvider(
LocalProviderConfig.builder().encryptionKey(ENCRYPTION_KEY).build(),
FLAG_CLIENT_SECRET);
OpenFeatureAPI.getInstance().setProviderAndWait("encrypted-e2e", provider);
final EvaluationContext context = new MutableContext("test-a").add("sticky", false);
OpenFeatureAPI.getInstance().setEvaluationContext(context);
client = OpenFeatureAPI.getInstance().getClient("encrypted-e2e");
}

@AfterAll
static void teardown() {
OpenFeatureAPI.getInstance().shutdown();
}

@Test
void shouldResolveBooleanViaEncryptedState() {
assertThat(client.getBooleanValue("web-sdk-e2e-flag.bool", true)).isFalse();
}

@Test
void shouldResolveStringViaEncryptedState() {
assertThat(client.getStringValue("web-sdk-e2e-flag.str", "default")).isEqualTo("control");
}

@Test
void shouldResolveDetailsViaEncryptedState() {
final FlagEvaluationDetails<Double> details =
client.getDoubleDetails("web-sdk-e2e-flag.obj.double", 1.0);
assertThat(details.getValue()).isEqualTo(3.6);
assertThat(details.getReason()).isEqualTo("RESOLVE_REASON_MATCH");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ void setup() {

// Create a state provider that fetches from the real Confidence service
final var stateProvider =
new FlagsAdminStateFetcher(FLAG_CLIENT_SECRET, new DefaultHttpClientFactory());
new FlagsAdminStateFetcher(FLAG_CLIENT_SECRET, new DefaultHttpClientFactory(), null);
stateProvider.reload();

// Create provider with capturing logger
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class WasmResolveApiFlushCloseRaceTest {
@BeforeAll
static void fetchState() {
final var stateProvider =
new FlagsAdminStateFetcher(FLAG_CLIENT_SECRET, new DefaultHttpClientFactory());
new FlagsAdminStateFetcher(FLAG_CLIENT_SECRET, new DefaultHttpClientFactory(), null);
stateProvider.reload();
resolverState = stateProvider.provide();
accountId = stateProvider.accountId();
Expand Down