Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -43,7 +46,8 @@
* return ctx.merge(new ImmutableContext(userIds.get(0)));
* }
* return ctx;
* }));
* }),
* sealer);
*
* // Register endpoints
* app.post("/v1/flags:resolve", ctx -> {
Expand Down Expand Up @@ -74,14 +78,15 @@ public class FlagResolverService<R extends ConfidenceHttpRequest> {

private final OpenFeatureLocalResolveProvider provider;
private final ContextDecorator<R> 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);
}

/**
Expand All @@ -92,8 +97,39 @@ public FlagResolverService(OpenFeatureLocalResolveProvider provider) {
*/
public FlagResolverService(
OpenFeatureLocalResolveProvider provider, ContextDecorator<R> contextDecorator) {
this(provider, contextDecorator, null);
}

/**
* Creates a new FlagResolverService with token sealing.
*
* <p>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<R> contextDecorator,
ResolveTokenSealer tokenSealer) {
this.provider = provider;
this.contextDecorator = contextDecorator;
this.tokenSealer = tokenSealer;
}

/**
Expand Down Expand Up @@ -151,7 +187,8 @@ public CompletionStage<ConfidenceHttpResponse> 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);
Expand Down Expand Up @@ -194,8 +231,8 @@ public CompletionStage<ConfidenceHttpResponse> 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);
Expand All @@ -206,6 +243,9 @@ public CompletionStage<ConfidenceHttpResponse> 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));
Expand Down Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Usage:
*
* <pre>{@code
* // Generate a key: openssl rand -hex 32
* ResolveTokenSealer sealer = ResolveTokenSealer.create("my-secret-key");
* FlagResolverService service = new FlagResolverService<>(provider, sealer);
* }</pre>
*/
@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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConfidenceHttpRequest> 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<ConfidenceHttpRequest> 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<ApplyFlagsRequest> 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<ConfidenceHttpRequest> 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<ConfidenceHttpRequest> 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")));
Expand Down
Loading