Skip to content

Commit 18fbca9

Browse files
nicklaslclaude
andcommitted
feat(java): support encrypted CDN resolver state
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 35d700e commit 18fbca9

11 files changed

Lines changed: 237 additions & 25 deletions

File tree

openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/FlagsAdminStateFetcher.java

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class FlagsAdminStateFetcher implements AccountStateProvider {
2525
"https://confidence-resolver-state-cdn.spotifycdn.com/";
2626

2727
private final String clientSecret;
28+
private final String encryptionKey;
2829
private final HttpClientFactory httpClientFactory;
2930
// ETag for conditional GETs of resolver state
3031
private final AtomicReference<String> etagHolder = new AtomicReference<>();
@@ -33,19 +34,34 @@ class FlagsAdminStateFetcher implements AccountStateProvider {
3334
com.spotify.confidence.sdk.flags.admin.v1.ResolverState.newBuilder()
3435
.build()
3536
.toByteArray());
37+
private final AtomicReference<byte[]> rawCdnBytesHolder = new AtomicReference<>();
3638
private String accountId = "";
3739

3840
public FlagsAdminStateFetcher(String clientSecret, HttpClientFactory httpClientFactory) {
41+
this(clientSecret, httpClientFactory, null);
42+
}
43+
44+
public FlagsAdminStateFetcher(
45+
String clientSecret, HttpClientFactory httpClientFactory, String encryptionKey) {
3946
this.clientSecret = clientSecret;
4047
this.httpClientFactory = httpClientFactory;
48+
this.encryptionKey = encryptionKey;
4149
}
4250

4351
public AtomicReference<byte[]> rawStateHolder() {
4452
return rawResolverStateHolder;
4553
}
4654

55+
public byte[] rawCdnBytes() {
56+
return rawCdnBytesHolder.get();
57+
}
58+
4759
@Override
4860
public byte[] provide() {
61+
final byte[] cdnBytes = rawCdnBytesHolder.get();
62+
if (cdnBytes != null) {
63+
return cdnBytes;
64+
}
4965
return rawResolverStateHolder.get();
5066
}
5167

@@ -64,32 +80,34 @@ public void reload() {
6480
}
6581

6682
private void fetchAndUpdateStateIfChanged() {
67-
// Build CDN URL using SHA256 hash of client secret
68-
final var cdnUrl = CDN_BASE_URL + sha256Hex(clientSecret);
83+
final var hash = sha256Hex(clientSecret);
84+
final boolean useEncrypted = encryptionKey != null && !encryptionKey.isEmpty();
85+
final var cdnUrl = CDN_BASE_URL + (useEncrypted ? hash + ".enc" : hash);
6986
try {
7087
final HttpURLConnection conn = httpClientFactory.create(cdnUrl);
7188
final String previousEtag = etagHolder.get();
7289
if (previousEtag != null) {
7390
conn.setRequestProperty("if-none-match", previousEtag);
7491
}
7592
if (conn.getResponseCode() == 304) {
76-
// Not modified
7793
return;
7894
}
7995
final String etag = conn.getHeaderField("etag");
8096
try (final InputStream stream = conn.getInputStream()) {
8197
final byte[] bytes = stream.readAllBytes();
8298

83-
// Parse SetResolverStateRequest from CDN response
84-
final var stateRequest =
85-
com.spotify.confidence.sdk.wasm.Messages.SetResolverStateRequest.parseFrom(bytes);
86-
this.accountId = stateRequest.getAccountId();
87-
88-
// Store the state bytes (already in bytes format)
89-
rawResolverStateHolder.set(stateRequest.getState().toByteArray());
99+
if (useEncrypted) {
100+
rawCdnBytesHolder.set(bytes);
101+
} else {
102+
final var stateRequest =
103+
com.spotify.confidence.sdk.wasm.Messages.SetResolverStateRequest.parseFrom(bytes);
104+
this.accountId = stateRequest.getAccountId();
105+
rawResolverStateHolder.set(stateRequest.getState().toByteArray());
106+
rawCdnBytesHolder.set(null);
107+
}
90108
etagHolder.set(etag);
91109
}
92-
logger.info("Loaded resolver state for account={}, etag={}", accountId, etag);
110+
logger.info("Loaded resolver state (encrypted={}, etag={})", useEncrypted, etag);
93111
} catch (IOException e) {
94112
throw new RuntimeException(e);
95113
}

openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalProviderConfig.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public class LocalProviderConfig {
1111
private final HttpClientFactory httpClientFactory;
1212
private final boolean useRemoteMaterializationStore;
1313
private final int resolverPoolSize;
14+
private final String encryptionKey;
1415

1516
public LocalProviderConfig() {
1617
this(null, null);
@@ -36,11 +37,21 @@ public LocalProviderConfig(
3637
HttpClientFactory httpClientFactory,
3738
boolean useRemoteMaterializationStore,
3839
int resolverPoolSize) {
40+
this(channelFactory, httpClientFactory, useRemoteMaterializationStore, resolverPoolSize, null);
41+
}
42+
43+
private LocalProviderConfig(
44+
ChannelFactory channelFactory,
45+
HttpClientFactory httpClientFactory,
46+
boolean useRemoteMaterializationStore,
47+
int resolverPoolSize,
48+
String encryptionKey) {
3949
this.channelFactory = channelFactory != null ? channelFactory : new DefaultChannelFactory();
4050
this.httpClientFactory =
4151
httpClientFactory != null ? httpClientFactory : new DefaultHttpClientFactory();
4252
this.useRemoteMaterializationStore = useRemoteMaterializationStore;
4353
this.resolverPoolSize = resolverPoolSize > 0 ? resolverPoolSize : DEFAULT_RESOLVER_POOL_SIZE;
54+
this.encryptionKey = encryptionKey;
4455
}
4556

4657
public ChannelFactory getChannelFactory() {
@@ -63,6 +74,11 @@ public int getResolverPoolSize() {
6374
return resolverPoolSize;
6475
}
6576

77+
/** Returns the hex-encoded AES-256 encryption key, or {@code null} if unset. */
78+
public String getEncryptionKey() {
79+
return encryptionKey;
80+
}
81+
6682
public static Builder builder() {
6783
return new Builder();
6884
}
@@ -72,6 +88,7 @@ public static class Builder {
7288
private HttpClientFactory httpClientFactory;
7389
private boolean useRemoteMaterializationStore;
7490
private int resolverPoolSize;
91+
private String encryptionKey;
7592

7693
public Builder channelFactory(ChannelFactory channelFactory) {
7794
this.channelFactory = channelFactory;
@@ -100,9 +117,19 @@ public Builder resolverPoolSize(int resolverPoolSize) {
100117
return this;
101118
}
102119

120+
/** Sets the hex-encoded AES-256 encryption key for decrypting CDN state. */
121+
public Builder encryptionKey(String encryptionKey) {
122+
this.encryptionKey = encryptionKey;
123+
return this;
124+
}
125+
103126
public LocalProviderConfig build() {
104127
return new LocalProviderConfig(
105-
channelFactory, httpClientFactory, useRemoteMaterializationStore, resolverPoolSize);
128+
channelFactory,
129+
httpClientFactory,
130+
useRemoteMaterializationStore,
131+
resolverPoolSize,
132+
encryptionKey);
106133
}
107134
}
108135
}

openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/LocalResolver.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ interface LocalResolver {
1919
*/
2020
void setResolverState(byte[] state, String accountId, Sdk sdk);
2121

22+
void setEncryptedResolverState(byte[] encryptedState, byte[] encryptionKey, Sdk sdk);
23+
2224
/**
2325
* Resolves flags. The returned stage completes when all resolution (including any store I/O for
2426
* materializations) has finished.

openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/MaterializingResolver.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ public void setResolverState(byte[] state, String accountId, Sdk sdk) {
167167
delegate.setResolverState(state, accountId, sdk);
168168
}
169169

170+
@Override
171+
public void setEncryptedResolverState(byte[] encryptedState, byte[] encryptionKey, Sdk sdk) {
172+
delegate.setEncryptedResolverState(encryptedState, encryptionKey, sdk);
173+
}
174+
170175
@Override
171176
public void registerResolve(RegisterResolveRequest request) {
172177
delegate.registerResolve(request);

openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/OpenFeatureLocalResolveProvider.java

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ public class OpenFeatureLocalResolveProvider implements FeatureProvider {
6464
private final AccountStateProvider stateProvider;
6565
private final AtomicReference<ProviderState> state =
6666
new AtomicReference<>(ProviderState.NOT_READY);
67+
private final String encryptionKey;
6768
private volatile boolean initialized = false;
6869
private volatile byte[] lastStateBytes = null;
6970
@VisibleForTesting boolean forcedFetcherShutdown = false;
@@ -147,8 +148,11 @@ public OpenFeatureLocalResolveProvider(
147148
public OpenFeatureLocalResolveProvider(
148149
LocalProviderConfig config, String clientSecret, MaterializationStore materializationStore) {
149150
this.clientSecret = clientSecret;
151+
this.encryptionKey = config.getEncryptionKey();
150152
this.materializationStore = materializationStore;
151-
this.stateProvider = new FlagsAdminStateFetcher(clientSecret, config.getHttpClientFactory());
153+
this.stateProvider =
154+
new FlagsAdminStateFetcher(
155+
clientSecret, config.getHttpClientFactory(), config.getEncryptionKey());
152156
final var wasmFlagLogger = new GrpcWasmFlagLogger(clientSecret, config.getChannelFactory());
153157
this.flagLogger = wasmFlagLogger;
154158
final int numInstances = PooledResolver.getNumInstances(config.getResolverPoolSize());
@@ -174,6 +178,7 @@ public OpenFeatureLocalResolveProvider(
174178
MaterializationStore materializationStore,
175179
WasmFlagLogger wasmFlagLogger) {
176180
this.clientSecret = clientSecret;
181+
this.encryptionKey = null;
177182
this.materializationStore = materializationStore;
178183
this.stateProvider = accountStateProvider;
179184
this.flagLogger = wasmFlagLogger;
@@ -198,12 +203,19 @@ public void initialize(EvaluationContext evaluationContext) {
198203
new AtomicReference<>(stateProvider.provide());
199204
final AtomicReference<String> accountIdRef = new AtomicReference<>(stateProvider.accountId());
200205

201-
// Only initialize WASM and set READY if we got valid state (non-empty accountId)
202-
if (!accountIdRef.get().isEmpty()) {
206+
if (encryptionKey != null) {
207+
final byte[] stateBytes = resolverStateProtobuf.get();
208+
if (stateBytes != null && stateBytes.length > 0) {
209+
resolver.setEncryptedResolverState(stateBytes, hexToBytes(encryptionKey), SDK);
210+
initialized = true;
211+
this.state.set(ProviderState.READY);
212+
}
213+
} else if (!accountIdRef.get().isEmpty()) {
203214
resolver.setResolverState(resolverStateProtobuf.get(), accountIdRef.get(), SDK);
204215
initialized = true;
205216
this.state.set(ProviderState.READY);
206-
} else {
217+
}
218+
if (!initialized) {
207219
log.warn(
208220
"Initial state load failed, provider starting in NOT_READY state, serving default"
209221
+ " values.");
@@ -245,22 +257,31 @@ private void scheduleStateRefresh(
245257
resolverStateProtobuf.set(stateProvider.provide());
246258
accountIdRef.set(stateProvider.accountId());
247259

248-
if (!accountIdRef.get().isEmpty()) {
260+
final byte[] newState = resolverStateProtobuf.get();
261+
final boolean hasState =
262+
encryptionKey != null
263+
? (newState != null && newState.length > 0)
264+
: !accountIdRef.get().isEmpty();
265+
if (hasState) {
249266
if (!initialized) {
250-
resolver.setResolverState(resolverStateProtobuf.get(), accountIdRef.get(), SDK);
251-
lastStateBytes = resolverStateProtobuf.get();
267+
if (encryptionKey != null) {
268+
resolver.setEncryptedResolverState(newState, hexToBytes(encryptionKey), SDK);
269+
} else {
270+
resolver.setResolverState(newState, accountIdRef.get(), SDK);
271+
}
272+
lastStateBytes = newState;
252273
initialized = true;
253274
this.state.set(ProviderState.READY);
254275
log.info("Provider recovered and is now READY");
255276
} else {
256-
// Only push state into the wasm instances when it actually changed — the wasm
257-
// execution inside setResolverState is expensive (runs across all pool slots).
258-
final byte[] newState = resolverStateProtobuf.get();
259277
if (!java.util.Arrays.equals(newState, lastStateBytes)) {
260-
resolver.setResolverState(newState, accountIdRef.get(), SDK);
278+
if (encryptionKey != null) {
279+
resolver.setEncryptedResolverState(newState, hexToBytes(encryptionKey), SDK);
280+
} else {
281+
resolver.setResolverState(newState, accountIdRef.get(), SDK);
282+
}
261283
lastStateBytes = newState;
262284
}
263-
// Always flush logs regardless of state change.
264285
resolver.flushAllLogs();
265286
}
266287
}
@@ -274,6 +295,10 @@ private void scheduleStateRefresh(
274295
TimeUnit.SECONDS);
275296
}
276297

298+
private static byte[] hexToBytes(String hex) {
299+
return java.util.HexFormat.of().parseHex(hex);
300+
}
301+
277302
@Override
278303
public Metadata getMetadata() {
279304
return () -> "confidence-sdk-java-local";

openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/PooledResolver.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ public void setResolverState(byte[] state, String accountId, Sdk sdk) {
7272
maintenance(lr -> lr.setResolverState(state, accountId, sdk));
7373
}
7474

75+
@Override
76+
public void setEncryptedResolverState(byte[] encryptedState, byte[] encryptionKey, Sdk sdk) {
77+
maintenance(lr -> lr.setEncryptedResolverState(encryptedState, encryptionKey, sdk));
78+
}
79+
7580
@Override
7681
public void flushAllLogs() {
7782
maintenance(LocalResolver::flushAllLogs);

openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/RecoveringResolver.java

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,13 @@ class RecoveringResolver implements LocalResolver {
2424

2525
private record StateRecord(byte[] state, String accountId, Sdk sdk) {}
2626

27+
private record EncryptedStateRecord(byte[] encryptedState, byte[] encryptionKey, Sdk sdk) {}
28+
2729
private final Supplier<LocalResolver> factory;
2830
private final AtomicReference<LocalResolver> current = new AtomicReference<>();
2931
private final AtomicBoolean broken = new AtomicBoolean(false);
3032
private final AtomicReference<StateRecord> lastState = new AtomicReference<>();
33+
private final AtomicReference<EncryptedStateRecord> lastEncryptedState = new AtomicReference<>();
3134

3235
RecoveringResolver(Supplier<LocalResolver> factory) {
3336
this.factory = factory;
@@ -41,8 +44,14 @@ private void startRecreate() {
4144
try {
4245
final LocalResolver old = current.get();
4346
final LocalResolver newResolver = factory.get();
47+
final EncryptedStateRecord cachedEncrypted = lastEncryptedState.get();
4448
final StateRecord cached = lastState.get();
45-
if (cached != null) {
49+
if (cachedEncrypted != null) {
50+
newResolver.setEncryptedResolverState(
51+
cachedEncrypted.encryptedState(),
52+
cachedEncrypted.encryptionKey(),
53+
cachedEncrypted.sdk());
54+
} else if (cached != null) {
4655
newResolver.setResolverState(cached.state(), cached.accountId(), cached.sdk());
4756
}
4857
current.set(newResolver);
@@ -84,12 +93,25 @@ public void setResolverState(byte[] state, String accountId, Sdk sdk) {
8493
try {
8594
current.get().setResolverState(state, accountId, sdk);
8695
lastState.set(new StateRecord(state, accountId, sdk));
96+
lastEncryptedState.set(null);
8797
} catch (ChicoryException e) {
8898
handleFailure("setResolverState", e);
8999
throw e;
90100
}
91101
}
92102

103+
@Override
104+
public void setEncryptedResolverState(byte[] encryptedState, byte[] encryptionKey, Sdk sdk) {
105+
try {
106+
current.get().setEncryptedResolverState(encryptedState, encryptionKey, sdk);
107+
lastEncryptedState.set(new EncryptedStateRecord(encryptedState, encryptionKey, sdk));
108+
lastState.set(null);
109+
} catch (ChicoryException e) {
110+
handleFailure("setEncryptedResolverState", e);
111+
throw e;
112+
}
113+
}
114+
93115
@Override
94116
public CompletionStage<ResolveProcessResponse> resolveProcess(ResolveProcessRequest request) {
95117
try {

openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/WasmLocalResolver.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class WasmLocalResolver implements LocalResolver {
5252

5353
// api
5454
private final ExportFunction wasmMsgGuestSetResolverState;
55+
private final ExportFunction wasmMsgGuestSetEncryptedResolverState;
5556
private final ExportFunction wasmMsgGuestRegisterResolve;
5657
private final ExportFunction wasmMsgBoundedFlushLogs;
5758
private final ExportFunction wasmMsgBoundedFlushAssign;
@@ -84,6 +85,8 @@ public WasmLocalResolver(Consumer<WriteFlagLogsRequest> logSink) {
8485
wasmMsgAlloc = instance.export("wasm_msg_alloc");
8586
wasmMsgFree = instance.export("wasm_msg_free");
8687
wasmMsgGuestSetResolverState = instance.export("wasm_msg_guest_set_resolver_state");
88+
wasmMsgGuestSetEncryptedResolverState =
89+
instance.export("wasm_msg_guest_set_encrypted_resolver_state");
8790
wasmMsgGuestRegisterResolve = instance.export("wasm_msg_guest_register_resolve");
8891
wasmMsgBoundedFlushLogs = instance.export("wasm_msg_guest_bounded_flush_logs");
8992
wasmMsgBoundedFlushAssign = instance.export("wasm_msg_guest_bounded_flush_assign");
@@ -131,6 +134,30 @@ public void setResolverState(byte[] state, String accountId, Sdk sdk) {
131134
}
132135
}
133136

137+
@Override
138+
public void setEncryptedResolverState(byte[] encryptedState, byte[] encryptionKey, Sdk sdk) {
139+
lock.lock();
140+
try {
141+
final var builder =
142+
Messages.SetEncryptedResolverStateRequest.newBuilder()
143+
.setEncryptedState(ByteString.copyFrom(encryptedState))
144+
.setEncryptionKey(ByteString.copyFrom(encryptionKey));
145+
if (sdk != null) {
146+
builder.setSdk(sdk);
147+
}
148+
final byte[] request =
149+
Messages.Request.newBuilder()
150+
.setData(ByteString.copyFrom(builder.build().toByteArray()))
151+
.build()
152+
.toByteArray();
153+
final int addr = transfer(request);
154+
final int respPtr = (int) wasmMsgGuestSetEncryptedResolverState.apply(addr)[0];
155+
consumeResponse(respPtr, Messages.Void::parseFrom);
156+
} finally {
157+
lock.unlock();
158+
}
159+
}
160+
134161
@Override
135162
public CompletionStage<ResolveProcessResponse> resolveProcess(ResolveProcessRequest request) {
136163
lock.lock();

0 commit comments

Comments
 (0)