From 0333e7d26555556fe7aedb411f84c51c0df77f52 Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 11 May 2026 16:22:24 +0200 Subject: [PATCH 1/2] feat(java): add AES-256-GCM resolve token sealing to FlagResolverService Co-Authored-By: Claude Opus 4.6 (1M context) --- .../confidence/sdk/FlagResolverService.java | 70 +++++++++- .../confidence/sdk/ResolveTokenSealer.java | 113 ++++++++++++++++ .../sdk/FlagResolverServiceTest.java | 123 ++++++++++++++++++ .../sdk/ResolveTokenSealerTest.java | 56 ++++++++ 4 files changed, 355 insertions(+), 7 deletions(-) create mode 100644 openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ResolveTokenSealer.java create mode 100644 openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTokenSealerTest.java diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/FlagResolverService.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/FlagResolverService.java index 29f930b4..c01a65c7 100644 --- a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/FlagResolverService.java +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/FlagResolverService.java @@ -1,10 +1,12 @@ package com.spotify.confidence.sdk; +import com.google.protobuf.ByteString; import com.google.protobuf.InvalidProtocolBufferException; import com.google.protobuf.Struct; import com.google.protobuf.util.JsonFormat; import com.spotify.confidence.sdk.flags.resolver.v1.ApplyFlagsRequest; import com.spotify.confidence.sdk.flags.resolver.v1.ResolveFlagsRequest; +import com.spotify.confidence.sdk.flags.resolver.v1.ResolveFlagsResponse; import dev.openfeature.sdk.EvaluationContext; import dev.openfeature.sdk.ImmutableContext; import dev.openfeature.sdk.MutableContext; @@ -34,7 +36,8 @@ * new OpenFeatureLocalResolveProvider("client-secret"); * OpenFeatureAPI.getInstance().setProviderAndWait(provider); * - * // Create service with optional context decoration + * // Create service with token sealing and optional context decoration + * ResolveTokenSealer sealer = ResolveTokenSealer.create(System.getenv("CONFIDENCE_TOKEN_KEY")); * FlagResolverService flagResolver = new FlagResolverService(provider, * ContextDecorator.sync((ctx, req) -> { * // Set targeting key from auth middleware header @@ -43,7 +46,8 @@ * return ctx.merge(new ImmutableContext(userIds.get(0))); * } * return ctx; - * })); + * }), + * sealer); * * // Register endpoints * app.post("/v1/flags:resolve", ctx -> { @@ -74,14 +78,15 @@ public class FlagResolverService { private final OpenFeatureLocalResolveProvider provider; private final ContextDecorator contextDecorator; + private final ResolveTokenSealer tokenSealer; /** - * Creates a new FlagResolverService with no context decoration. + * Creates a new FlagResolverService with no context decoration or token sealing. * * @param provider the local resolve provider to use for flag resolution */ public FlagResolverService(OpenFeatureLocalResolveProvider provider) { - this(provider, ContextDecorator.sync((ctx, req) -> ctx)); + this(provider, ContextDecorator.sync((ctx, req) -> ctx), null); } /** @@ -92,8 +97,39 @@ public FlagResolverService(OpenFeatureLocalResolveProvider provider) { */ public FlagResolverService( OpenFeatureLocalResolveProvider provider, ContextDecorator contextDecorator) { + this(provider, contextDecorator, null); + } + + /** + * Creates a new FlagResolverService with token sealing. + * + *

When a sealer is provided, the {@code resolve_token} in resolve responses is encrypted + * (AES-256-GCM) so clients only see an opaque handle. The token is decrypted transparently when + * it comes back via apply. This prevents clients from inspecting the raw resolve token which + * carries the evaluation context and resolved variants. + * + * @param provider the local resolve provider to use for flag resolution + * @param tokenSealer sealer for encrypting resolve tokens sent to clients + */ + public FlagResolverService( + OpenFeatureLocalResolveProvider provider, ResolveTokenSealer tokenSealer) { + this(provider, ContextDecorator.sync((ctx, req) -> ctx), tokenSealer); + } + + /** + * Creates a new FlagResolverService with context decoration and token sealing. + * + * @param provider the local resolve provider to use for flag resolution + * @param contextDecorator decorator to add additional context from requests + * @param tokenSealer sealer for encrypting resolve tokens (may be {@code null} to disable) + */ + public FlagResolverService( + OpenFeatureLocalResolveProvider provider, + ContextDecorator contextDecorator, + ResolveTokenSealer tokenSealer) { this.provider = provider; this.contextDecorator = contextDecorator; + this.tokenSealer = tokenSealer; } /** @@ -151,7 +187,8 @@ public CompletionStage handleResolve(R request) { .thenApply( response -> { try { - final String jsonResponse = JSON_PRINTER.print(response); + final var sealed = sealResolveToken(response); + final String jsonResponse = JSON_PRINTER.print(sealed); return ConfidenceHttpResponse.ok(jsonResponse); } catch (InvalidProtocolBufferException e) { log.warn("Invalid response format", e); @@ -194,8 +231,8 @@ public CompletionStage handleApply(R request) { final ApplyFlagsRequest.Builder applyRequestBuilder = ApplyFlagsRequest.newBuilder(); JSON_PARSER.merge(requestBody, applyRequestBuilder); - // Build the apply request - the resolve token is already in the protobuf - final ApplyFlagsRequest applyRequest = applyRequestBuilder.build(); + // Open sealed token if a sealer is configured, then build the final request + final ApplyFlagsRequest applyRequest = openResolveToken(applyRequestBuilder).build(); // Apply each flag provider.applyFlags(applyRequest); @@ -206,6 +243,9 @@ public CompletionStage handleApply(R request) { } catch (InvalidProtocolBufferException e) { log.warn("Invalid request format", e); return CompletableFuture.completedFuture(ConfidenceHttpResponse.error(400)); + } catch (IllegalArgumentException e) { + log.warn("Invalid resolve token", e); + return CompletableFuture.completedFuture(ConfidenceHttpResponse.error(400)); } catch (Exception e) { log.error("Error applying flags", e); return CompletableFuture.completedFuture(ConfidenceHttpResponse.error(500)); @@ -275,6 +315,22 @@ private MutableStructure protoStructToOpenFeatureStructure(Struct struct) { return new MutableStructure(map); } + private ResolveFlagsResponse sealResolveToken(ResolveFlagsResponse response) { + if (tokenSealer == null || response.getResolveToken().isEmpty()) { + return response; + } + byte[] sealed = tokenSealer.seal(response.getResolveToken().toByteArray()); + return response.toBuilder().setResolveToken(ByteString.copyFrom(sealed)).build(); + } + + private ApplyFlagsRequest.Builder openResolveToken(ApplyFlagsRequest.Builder builder) { + if (tokenSealer == null || builder.getResolveToken().isEmpty()) { + return builder; + } + byte[] opened = tokenSealer.open(builder.getResolveToken().toByteArray()); + return builder.setResolveToken(ByteString.copyFrom(opened)); + } + private static boolean isJsonContentType(ConfidenceHttpRequest request) { return request.headers().entrySet().stream() .filter(e -> e.getKey().equalsIgnoreCase("Content-Type")) diff --git a/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ResolveTokenSealer.java b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ResolveTokenSealer.java new file mode 100644 index 00000000..cc7f73db --- /dev/null +++ b/openfeature-provider/java/src/main/java/com/spotify/confidence/sdk/ResolveTokenSealer.java @@ -0,0 +1,113 @@ +package com.spotify.confidence.sdk; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * Seals and opens resolve tokens using AES-256-GCM. The resolve token carries the full evaluation + * context and the resolved variants — this class ensures the client only ever sees an opaque, + * encrypted handle that it round-trips through the apply endpoint. + * + *

Usage: + * + *

{@code
+ * // Generate a key: openssl rand -hex 32
+ * ResolveTokenSealer sealer = ResolveTokenSealer.create("my-secret-key");
+ * FlagResolverService service = new FlagResolverService<>(provider, sealer);
+ * }
+ */ +@Experimental +public final class ResolveTokenSealer { + private static final String ALGO = "AES/GCM/NoPadding"; + private static final int IV_LEN = 12; + private static final int TAG_BITS = 128; + private static final int TAG_LEN = TAG_BITS / 8; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final byte[] keyBytes; + + private ResolveTokenSealer(byte[] keyBytes) { + this.keyBytes = keyBytes; + } + + /** + * Creates a sealer from a raw key string. The key is derived via SHA-256 to produce a 256-bit AES + * key. Generate one with: {@code openssl rand -hex 32} + * + * @param rawKey the secret key (any length; will be SHA-256 hashed) + * @return a new sealer + */ + public static ResolveTokenSealer create(String rawKey) { + try { + byte[] derived = + MessageDigest.getInstance("SHA-256").digest(rawKey.getBytes(StandardCharsets.UTF_8)); + return new ResolveTokenSealer(derived); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + /** + * Encrypts a resolve token. Each call produces a different ciphertext (random IV). + * + * @param plaintext the raw resolve token bytes + * @return sealed bytes: {@code IV (12) || GCM tag (16) || ciphertext} + */ + byte[] seal(byte[] plaintext) { + try { + byte[] iv = new byte[IV_LEN]; + RANDOM.nextBytes(iv); + Cipher cipher = Cipher.getInstance(ALGO); + cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), gcmSpec(iv)); + byte[] ciphertextAndTag = cipher.doFinal(plaintext); + // Java appends the tag to ciphertext. Rearrange to: IV || tag || ciphertext + // to match the JS SDK wire format. + int ciphertextLen = ciphertextAndTag.length - TAG_LEN; + byte[] result = new byte[IV_LEN + TAG_LEN + ciphertextLen]; + System.arraycopy(iv, 0, result, 0, IV_LEN); + System.arraycopy(ciphertextAndTag, ciphertextLen, result, IV_LEN, TAG_LEN); + System.arraycopy(ciphertextAndTag, 0, result, IV_LEN + TAG_LEN, ciphertextLen); + return result; + } catch (GeneralSecurityException e) { + throw new IllegalStateException("AES-GCM seal failed", e); + } + } + + /** + * Decrypts a sealed resolve token. + * + * @param sealed the sealed bytes (as produced by {@link #seal}) + * @return the original plaintext bytes + * @throws IllegalArgumentException if the handle is too short or tampered with + */ + byte[] open(byte[] sealed) { + if (sealed.length < IV_LEN + TAG_LEN) { + throw new IllegalArgumentException("Invalid Confidence handle"); + } + try { + byte[] iv = new byte[IV_LEN]; + System.arraycopy(sealed, 0, iv, 0, IV_LEN); + byte[] tag = new byte[TAG_LEN]; + System.arraycopy(sealed, IV_LEN, tag, 0, TAG_LEN); + int ciphertextLen = sealed.length - IV_LEN - TAG_LEN; + // Reassemble to Java's expected format: ciphertext || tag + byte[] ciphertextAndTag = new byte[ciphertextLen + TAG_LEN]; + System.arraycopy(sealed, IV_LEN + TAG_LEN, ciphertextAndTag, 0, ciphertextLen); + System.arraycopy(tag, 0, ciphertextAndTag, ciphertextLen, TAG_LEN); + Cipher cipher = Cipher.getInstance(ALGO); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), gcmSpec(iv)); + return cipher.doFinal(ciphertextAndTag); + } catch (GeneralSecurityException e) { + throw new IllegalArgumentException("Invalid Confidence handle", e); + } + } + + private static GCMParameterSpec gcmSpec(byte[] iv) { + return new GCMParameterSpec(TAG_BITS, iv); + } +} diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/FlagResolverServiceTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/FlagResolverServiceTest.java index d196a0e6..431edfb2 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/FlagResolverServiceTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/FlagResolverServiceTest.java @@ -750,6 +750,129 @@ void shouldAcceptMixedCasePost() { } } + @Nested + class TokenSealing { + + private final ResolveTokenSealer sealer = + ResolveTokenSealer.create("test-key-do-not-use-in-prod"); + + @Test + void resolveShouldSealTokenWhenSealerConfigured() { + FlagResolverService sealedService = + new FlagResolverService<>(mockProvider, sealer); + + String requestBody = + """ + { + "flags": ["flag1"], + "evaluationContext": {}, + "apply": false + } + """; + + ResolveFlagsResponse mockResponse = + ResolveFlagsResponse.newBuilder() + .setResolveToken(ByteString.copyFromUtf8("secret-token-value")) + .setResolveId("resolve-123") + .build(); + + when(mockProvider.resolve(any(EvaluationContext.class), anyList(), anyBoolean())) + .thenReturn(CompletableFuture.completedFuture(mockResponse)); + + ConfidenceHttpRequest request = createRequest("POST", requestBody); + ConfidenceHttpResponse response = + sealedService.handleResolve(request).toCompletableFuture().join(); + + assertThat(response.statusCode()).isEqualTo(200); + String body = readBody(response); + // The raw token should NOT appear in the response + assertThat(body).doesNotContain("secret-token-value"); + // But the response should still have a resolveToken field + assertThat(body).contains("resolveToken"); + } + + @Test + void applyShouldOpenSealedToken() { + FlagResolverService sealedService = + new FlagResolverService<>(mockProvider, sealer); + + // Seal a token the same way the resolve handler would + byte[] rawToken = "the-real-resolve-token".getBytes(StandardCharsets.UTF_8); + byte[] sealed = sealer.seal(rawToken); + String sealedBase64 = java.util.Base64.getEncoder().encodeToString(sealed); + + String requestBody = + """ + { + "flags": [{"flag": "flags/my-flag", "applyTime": "2025-02-12T12:34:56Z"}], + "resolveToken": "%s" + } + """ + .formatted(sealedBase64); + + ConfidenceHttpRequest request = createRequest("POST", requestBody); + ConfidenceHttpResponse response = + sealedService.handleApply(request).toCompletableFuture().join(); + + assertThat(response.statusCode()).isEqualTo(200); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ApplyFlagsRequest.class); + verify(mockProvider).applyFlags(captor.capture()); + // The provider should receive the original, unsealed token + assertThat(captor.getValue().getResolveToken().toStringUtf8()) + .isEqualTo("the-real-resolve-token"); + } + + @Test + void applyShouldReturn400ForTamperedToken() { + FlagResolverService sealedService = + new FlagResolverService<>(mockProvider, sealer); + + byte[] sealed = sealer.seal("some-token".getBytes(StandardCharsets.UTF_8)); + sealed[sealed.length - 1] ^= 0x01; + String tamperedBase64 = java.util.Base64.getEncoder().encodeToString(sealed); + + String requestBody = + """ + { + "flags": [{"flag": "flags/test"}], + "resolveToken": "%s" + } + """ + .formatted(tamperedBase64); + + ConfidenceHttpRequest request = createRequest("POST", requestBody); + ConfidenceHttpResponse response = + sealedService.handleApply(request).toCompletableFuture().join(); + + assertThat(response.statusCode()).isEqualTo(400); + } + + @Test + void applyShouldReturn400ForGarbageToken() { + FlagResolverService sealedService = + new FlagResolverService<>(mockProvider, sealer); + + // A short garbage value that won't pass validation + String garbageBase64 = java.util.Base64.getEncoder().encodeToString(new byte[] {1, 2, 3}); + + String requestBody = + """ + { + "flags": [{"flag": "flags/test"}], + "resolveToken": "%s" + } + """ + .formatted(garbageBase64); + + ConfidenceHttpRequest request = createRequest("POST", requestBody); + ConfidenceHttpResponse response = + sealedService.handleApply(request).toCompletableFuture().join(); + + assertThat(response.statusCode()).isEqualTo(400); + } + } + private ConfidenceHttpRequest createRequest(String method, String body) { return createRequestWithHeaders( method, body, Map.of("Content-Type", List.of("application/json"))); diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTokenSealerTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTokenSealerTest.java new file mode 100644 index 00000000..66cf8980 --- /dev/null +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTokenSealerTest.java @@ -0,0 +1,56 @@ +package com.spotify.confidence.sdk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class ResolveTokenSealerTest { + + private final ResolveTokenSealer sealer = ResolveTokenSealer.create("test-key-do-not-use-in-prod"); + + @Test + void roundTripsATypicalResolveToken() { + byte[] token = "abc.def.ghi-some-base64-ish-resolve-token==".getBytes(StandardCharsets.UTF_8); + byte[] sealed = sealer.seal(token); + assertThat(sealed).isNotEqualTo(token); + assertThat(sealer.open(sealed)).isEqualTo(token); + } + + @Test + void roundTripsEmptyBytes() { + byte[] sealed = sealer.seal(new byte[0]); + assertThat(sealer.open(sealed)).isEmpty(); + } + + @Test + void producesDifferentCiphertextEachCall() { + byte[] token = "same-input".getBytes(StandardCharsets.UTF_8); + assertThat(sealer.seal(token)).isNotEqualTo(sealer.seal(token)); + } + + @Test + void rejectsTamperedCiphertext() { + byte[] sealed = sealer.seal("something".getBytes(StandardCharsets.UTF_8)); + sealed[sealed.length - 1] ^= 0x01; + byte[] tampered = sealed; + assertThatThrownBy(() -> sealer.open(tampered)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsTooShortHandle() { + assertThatThrownBy(() -> sealer.open(new byte[] {1, 2, 3, 4})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid Confidence handle"); + } + + @Test + void rejectsHandleSealedWithDifferentKey() { + byte[] sealed = sealer.seal("payload".getBytes(StandardCharsets.UTF_8)); + ResolveTokenSealer other = ResolveTokenSealer.create("a-different-key"); + assertThatThrownBy(() -> other.open(sealed)) + .isInstanceOf(IllegalArgumentException.class); + } +} From a4ef3ad998d49e2aa42c45a2de3a55102aa62add Mon Sep 17 00:00:00 2001 From: Nicklas Lundin Date: Mon, 11 May 2026 16:59:46 +0200 Subject: [PATCH 2/2] style(java): fix formatting in ResolveTokenSealerTest Co-Authored-By: Claude Opus 4.6 (1M context) --- .../spotify/confidence/sdk/ResolveTokenSealerTest.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTokenSealerTest.java b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTokenSealerTest.java index 66cf8980..70e571fc 100644 --- a/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTokenSealerTest.java +++ b/openfeature-provider/java/src/test/java/com/spotify/confidence/sdk/ResolveTokenSealerTest.java @@ -8,7 +8,8 @@ class ResolveTokenSealerTest { - private final ResolveTokenSealer sealer = ResolveTokenSealer.create("test-key-do-not-use-in-prod"); + private final ResolveTokenSealer sealer = + ResolveTokenSealer.create("test-key-do-not-use-in-prod"); @Test void roundTripsATypicalResolveToken() { @@ -35,8 +36,7 @@ void rejectsTamperedCiphertext() { byte[] sealed = sealer.seal("something".getBytes(StandardCharsets.UTF_8)); sealed[sealed.length - 1] ^= 0x01; byte[] tampered = sealed; - assertThatThrownBy(() -> sealer.open(tampered)) - .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> sealer.open(tampered)).isInstanceOf(IllegalArgumentException.class); } @Test @@ -50,7 +50,6 @@ void rejectsTooShortHandle() { void rejectsHandleSealedWithDifferentKey() { byte[] sealed = sealer.seal("payload".getBytes(StandardCharsets.UTF_8)); ResolveTokenSealer other = ResolveTokenSealer.create("a-different-key"); - assertThatThrownBy(() -> other.open(sealed)) - .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> other.open(sealed)).isInstanceOf(IllegalArgumentException.class); } }