diff --git a/Dockerfile b/Dockerfile index 7b4ab5f2..34e2479c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 # ============================================================================== diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/FlagsAdminStateFetcher.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/FlagsAdminStateFetcher.java index ebaaa500..a3ada3ea 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/FlagsAdminStateFetcher.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/FlagsAdminStateFetcher.java @@ -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; @@ -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 etagHolder = new AtomicReference<>(); private final AtomicReference rawResolverStateHolder = new AtomicReference<>( @@ -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 rawStateHolder() { - return rawResolverStateHolder; + this.encryptionKey = encryptionKey; } @Override @@ -64,8 +65,8 @@ 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(); @@ -73,19 +74,19 @@ private void fetchAndUpdateStateIfChanged() { 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); } @@ -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"); diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalProviderConfig.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalProviderConfig.java index 173a4b81..1c2b9c4a 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalProviderConfig.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalProviderConfig.java @@ -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); @@ -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() { @@ -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(); } @@ -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; @@ -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); } } } diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java index ec3543b5..6ae099dd 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java @@ -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()); @@ -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; diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProviderEncryptedIT.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProviderEncryptedIT.java new file mode 100644 index 00000000..31172846 --- /dev/null +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProviderEncryptedIT.java @@ -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 details = + client.getDoubleDetails("web-sdk-e2e-flag.obj.double", 1.0); + assertThat(details.getValue()).isEqualTo(3.6); + assertThat(details.getReason()).isEqualTo("RESOLVE_REASON_MATCH"); + } +} diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProviderFlagLogsTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProviderFlagLogsTest.java index dd19cd75..70a1e354 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProviderFlagLogsTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProviderFlagLogsTest.java @@ -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 diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java index dea5ff64..cdd63258 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/WasmResolveApiFlushCloseRaceTest.java @@ -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();