From e938f71b5388fb6149fa3bdd84c2abc6ac4e36b6 Mon Sep 17 00:00:00 2001 From: Oleksandr Bernard Date: Wed, 4 Nov 2026 12:57:44 +0000 Subject: [PATCH 1/4] fix(sdk): read platform base keys from structured configuration Signed-off-by: Oleksandr Bernard --- .../java/io/opentdf/platform/sdk/Planner.java | 118 ++++++-- .../io/opentdf/platform/sdk/PlannerTest.java | 268 +++++++++++++++--- .../java/io/opentdf/platform/sdk/SDKTest.java | 12 +- 3 files changed, 340 insertions(+), 58 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java b/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java index 477d315a8..55cc8636e 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java @@ -4,6 +4,8 @@ import com.google.gson.Gson; import com.google.gson.JsonSyntaxException; import com.google.gson.annotations.SerializedName; +import com.google.protobuf.Struct; +import com.google.protobuf.Value; import io.opentdf.platform.policy.Algorithm; import io.opentdf.platform.policy.SimpleKasKey; import io.opentdf.platform.policy.SimpleKasPublicKey; @@ -95,45 +97,123 @@ static Optional fetchBaseKey(WellKnownServiceClientInterface wellk throw new SDKException("unable to retrieve base key from well known endpoint", e); } - String baseKeyJson; - try { - baseKeyJson = response - .getConfiguration() - .getFieldsOrThrow(BASE_KEY) - .getStringValue(); - } catch (IllegalArgumentException e) { - logger.info( "no `" + BASE_KEY + "` found in well known configuration.", e); + var baseKeyValue = response.getConfiguration().getFieldsMap().get(BASE_KEY); + if (baseKeyValue == null) { + logger.info("no `{}` found in well known configuration", BASE_KEY); return Optional.empty(); } BaseKey baseKey; - try { - baseKey = gson.fromJson(baseKeyJson, BaseKey.class); - } catch (JsonSyntaxException e) { - throw new SDKException("base key in well known configuration is malformed [" + baseKeyJson + "]", e); + if (baseKeyValue.getKindCase() == Value.KindCase.STRUCT_VALUE) { + baseKey = baseKeyFromStruct(baseKeyValue.getStructValue()); + } else if (baseKeyValue.getKindCase() == Value.KindCase.STRING_VALUE) { + // Preserve the JSON-string representation accepted by earlier SDK versions. + try { + baseKey = gson.fromJson(baseKeyValue.getStringValue(), BaseKey.class); + } catch (JsonSyntaxException e) { + throw new SDKException("base key in well known configuration is malformed", e); + } + } else { + logger.error("base key in well known configuration has an invalid type; base key will not be used"); + return Optional.empty(); } - if (baseKey == null || baseKey.kasUrl == null || baseKey.publicKey == null || baseKey.publicKey.kid == null || baseKey.publicKey.pem == null || baseKey.publicKey.algorithm == null) { - logger.error("base key in well known configuration is missing required fields [{}]. base key will not be used", baseKeyJson); + if (baseKey == null || isMissing(baseKey.kasUri) || baseKey.publicKey == null + || isMissing(baseKey.publicKey.kid) || isMissing(baseKey.publicKey.pem) + || isMissing(baseKey.publicKey.algorithm)) { + logger.error("base key in well known configuration is missing valid required fields; base key will not be used"); + return Optional.empty(); + } + + if (baseKeyValue.getKindCase() == Value.KindCase.STRING_VALUE + && ("ALGORITHM_UNSPECIFIED".equals(baseKey.publicKey.algorithm) + || "UNRECOGNIZED".equals(baseKey.publicKey.algorithm))) { + // These legacy enum values failed during planning or key construction. + // Do not turn them into an absent key that allows default-KAS fallback. + throw new SDKException("base key in well known configuration has an unsupported algorithm"); + } + + Algorithm algorithm; + try { + algorithm = baseKeyAlgorithm(baseKey.publicKey.algorithm); + } catch (IllegalArgumentException e) { + logger.error("base key in well known configuration has an unsupported algorithm; base key will not be used"); return Optional.empty(); } return Optional.of(SimpleKasKey.newBuilder() - .setKasUri(baseKey.kasUrl) + .setKasUri(baseKey.kasUri) .setPublicKey( SimpleKasPublicKey.newBuilder() .setKid(baseKey.publicKey.kid) - .setAlgorithm(baseKey.publicKey.algorithm) + .setAlgorithm(algorithm) .setPem(baseKey.publicKey.pem) .build()) .build()); } + private static BaseKey baseKeyFromStruct(Struct fields) { + var baseKey = new BaseKey(); + baseKey.kasUri = stringField(fields, "kas_uri"); + var publicKey = fields.getFieldsMap().get("public_key"); + if (publicKey != null && publicKey.getKindCase() == Value.KindCase.STRUCT_VALUE) { + var keyFields = publicKey.getStructValue(); + baseKey.publicKey = new BaseKey.Key(); + baseKey.publicKey.kid = stringField(keyFields, "kid"); + baseKey.publicKey.pem = stringField(keyFields, "pem"); + baseKey.publicKey.algorithm = stringField(keyFields, "algorithm"); + } + return baseKey; + } + + private static String stringField(Struct fields, String name) { + var value = fields.getFieldsMap().get(name); + return value != null && value.getKindCase() == Value.KindCase.STRING_VALUE + ? value.getStringValue() : null; + } + + private static boolean isMissing(String value) { + return value == null || value.isBlank(); + } + + private static Algorithm baseKeyAlgorithm(String value) { + if (value.startsWith("ALGORITHM_")) { + var algorithm = Algorithm.valueOf(value); + KeyType.fromAlgorithm(algorithm); + return algorithm; + } + + switch (KeyType.fromString(value)) { + case RSA2048Key: + return Algorithm.ALGORITHM_RSA_2048; + case RSA4096Key: + return Algorithm.ALGORITHM_RSA_4096; + case EC256Key: + return Algorithm.ALGORITHM_EC_P256; + case EC384Key: + return Algorithm.ALGORITHM_EC_P384; + case EC521Key: + return Algorithm.ALGORITHM_EC_P521; + case HybridXWingKey: + return Algorithm.ALGORITHM_HPQT_XWING; + case HybridSecp256r1MLKEM768Key: + return Algorithm.ALGORITHM_HPQT_SECP256R1_MLKEM768; + case HybridSecp384r1MLKEM1024Key: + return Algorithm.ALGORITHM_HPQT_SECP384R1_MLKEM1024; + case MLKEM768Key: + return Algorithm.ALGORITHM_MLKEM_768; + case MLKEM1024Key: + return Algorithm.ALGORITHM_MLKEM_1024; + default: + throw new IllegalArgumentException("Unsupported base key algorithm: " + value); + } + } + private static final Gson gson = new Gson(); private static class BaseKey { - @SerializedName("kas_url") - String kasUrl; + @SerializedName(value = "kas_uri", alternate = {"kas_url"}) + String kasUri; @SerializedName("public_key") Key publicKey; @@ -141,7 +221,7 @@ private static class BaseKey { private static class Key { String kid; String pem; - Algorithm algorithm; + String algorithm; } } diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java index c7316dd60..57df68f17 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java @@ -1,79 +1,273 @@ package io.opentdf.platform.sdk; +import com.google.protobuf.ListValue; +import com.google.protobuf.NullValue; import com.google.protobuf.Struct; import com.google.protobuf.Value; import io.opentdf.platform.policy.Algorithm; +import io.opentdf.platform.policy.Attribute; +import io.opentdf.platform.policy.AttributeRuleTypeEnum; import io.opentdf.platform.wellknownconfiguration.GetWellKnownConfigurationResponse; import io.opentdf.platform.wellknownconfiguration.WellKnownServiceClientInterface; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class PlannerTest { - @Test - void fetchBaseKey() { - var wellknownService = Mockito.mock(WellKnownServiceClientInterface.class); - var baseKeyJson = "{\"kas_url\":\"https://example.com/base_key\",\"public_key\":{\"algorithm\":\"ALGORITHM_RSA_2048\",\"kid\":\"thekid\",\"pem\": \"thepem\"}}"; - var val = Value.newBuilder().setStringValue(baseKeyJson).build(); - var config = Struct.newBuilder().putFields("base_key", val).build(); - var response = GetWellKnownConfigurationResponse - .newBuilder() - .setConfiguration(config) - .build(); - - Mockito.when(wellknownService.getWellKnownConfigurationBlocking(Mockito.any(), Mockito.anyMap())) - .thenReturn(TestUtil.successfulUnaryCall(response)); - + @ParameterizedTest + @CsvSource({ + "rsa:2048, ALGORITHM_RSA_2048", + "rsa:4096, ALGORITHM_RSA_4096", + "ec:secp256r1, ALGORITHM_EC_P256", + "ec:secp384r1, ALGORITHM_EC_P384", + "ec:secp521r1, ALGORITHM_EC_P521", + "hpqt:xwing, ALGORITHM_HPQT_XWING", + "hpqt:secp256r1-mlkem768, ALGORITHM_HPQT_SECP256R1_MLKEM768", + "hpqt:secp384r1-mlkem1024, ALGORITHM_HPQT_SECP384R1_MLKEM1024", + "mlkem:768, ALGORITHM_MLKEM_768", + "mlkem:1024, ALGORITHM_MLKEM_1024" + }) + void fetchBaseKeyFromStructuredConfiguration(String wireAlgorithm, Algorithm expectedAlgorithm) { + var wellknownService = wellknownService(baseKeyConfiguration(structuredBaseKey(wireAlgorithm))); var baseKey = Planner.fetchBaseKey(wellknownService); - assertThat(baseKey).isNotEmpty(); + + assertThat(baseKey).isPresent(); var simpleKasKey = baseKey.get(); assertThat(simpleKasKey.getKasUri()).isEqualTo("https://example.com/base_key"); - assertThat(simpleKasKey.getPublicKey().getAlgorithm()).isEqualTo(Algorithm.ALGORITHM_RSA_2048); + assertThat(simpleKasKey.getPublicKey().getAlgorithm()).isEqualTo(expectedAlgorithm); assertThat(simpleKasKey.getPublicKey().getKid()).isEqualTo("thekid"); assertThat(simpleKasKey.getPublicKey().getPem()).isEqualTo("thepem"); } @Test - void fetchBaseKeyWithNoBaseKey() { - var wellknownService = Mockito.mock(WellKnownServiceClientInterface.class); - var response = GetWellKnownConfigurationResponse - .newBuilder() - .setConfiguration(Struct.newBuilder().build()) + void fetchBaseKeyFromLegacyJsonString() { + var json = "{\"kas_url\":\"https://example.com/base_key\"," + + "\"public_key\":{\"algorithm\":\"ALGORITHM_RSA_2048\",\"kid\":\"thekid\",\"pem\":\"thepem\"}}"; + var configuration = Struct.newBuilder() + .putFields("base_key", Value.newBuilder().setStringValue(json).build()) .build(); - Mockito.when(wellknownService.getWellKnownConfigurationBlocking(Mockito.any(), Mockito.anyMap())) - .thenReturn(TestUtil.successfulUnaryCall(response)); + var baseKey = Planner.fetchBaseKey(wellknownService(configuration)); - var baseKey = Planner.fetchBaseKey(wellknownService); - assertThat(baseKey).isEmpty(); + assertThat(baseKey).isPresent(); + assertThat(baseKey.get().getKasUri()).isEqualTo("https://example.com/base_key"); + assertThat(baseKey.get().getPublicKey().getAlgorithm()).isEqualTo(Algorithm.ALGORITHM_RSA_2048); + assertThat(baseKey.get().getPublicKey().getKid()).isEqualTo("thekid"); + assertThat(baseKey.get().getPublicKey().getPem()).isEqualTo("thepem"); } @Test - void fetchBaseKeyWithMissingFields() { - var wellknownService = Mockito.mock(WellKnownServiceClientInterface.class); - // Missing 'kid', 'pem', and 'algorithm' in public_key - var baseKeyJson = "{\"kas_url\":\"https://example.com/base_key\",\"public_key\":{}}"; - var val = Value.newBuilder().setStringValue(baseKeyJson).build(); - var config = Struct.newBuilder().putFields("base_key", val).build(); - var response = GetWellKnownConfigurationResponse - .newBuilder() - .setConfiguration(config) + void fetchBaseKeyWithMalformedLegacyJsonStillThrows() { + var configuration = Struct.newBuilder() + .putFields("base_key", Value.newBuilder().setStringValue("{").build()) .build(); + var service = wellknownService(configuration); + + assertThatThrownBy(() -> Planner.fetchBaseKey(service)) + .isInstanceOf(SDKException.class) + .hasMessageContaining("malformed"); + } + + @Test + void fetchBaseKeyWithNoBaseKey() { + assertThat(Planner.fetchBaseKey(wellknownService(Struct.getDefaultInstance()))).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"kas_uri", "public_key", "public_key.algorithm", "public_key.kid", "public_key.pem"}) + void fetchBaseKeyWithMissingRequiredField(String field) { + var baseKey = replaceBaseKeyField(structuredBaseKey("rsa:2048"), field, null); + + assertThat(Planner.fetchBaseKey(wellknownService(baseKeyConfiguration(baseKey)))).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"kas_uri", "public_key", "public_key.algorithm", "public_key.kid", "public_key.pem"}) + void fetchBaseKeyWithWrongRequiredFieldType(String field) { + var baseKey = replaceBaseKeyField(structuredBaseKey("rsa:2048"), field, + Value.newBuilder().setNumberValue(123).build()); + + assertThat(Planner.fetchBaseKey(wellknownService(baseKeyConfiguration(baseKey)))).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"kas_uri", "public_key.algorithm", "public_key.kid", "public_key.pem"}) + void fetchBaseKeyWithBlankRequiredField(String field) { + var baseKey = replaceBaseKeyField(structuredBaseKey("rsa:2048"), field, + Value.newBuilder().setStringValue(" ").build()); + + assertThat(Planner.fetchBaseKey(wellknownService(baseKeyConfiguration(baseKey)))).isEmpty(); + } + + @ParameterizedTest + @MethodSource("invalidBaseKeyValues") + void fetchBaseKeyWithWrongValueType(Value baseKey) { + var configuration = Struct.newBuilder().putFields("base_key", baseKey).build(); + + assertThat(Planner.fetchBaseKey(wellknownService(configuration))).isEmpty(); + } + + private static Stream invalidBaseKeyValues() { + return Stream.of( + Value.getDefaultInstance(), + Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(), + Value.newBuilder().setNumberValue(123).build(), + Value.newBuilder().setBoolValue(true).build(), + Value.newBuilder().setListValue(ListValue.getDefaultInstance()).build()); + } + + @ParameterizedTest + @ValueSource(strings = {"rsa:1024", "unknown", "ALGORITHM_UNSPECIFIED", "UNRECOGNIZED"}) + void fetchBaseKeyRejectsUnsupportedAlgorithm(String algorithm) { + var wellknownService = wellknownService(baseKeyConfiguration(structuredBaseKey(algorithm))); + + assertThat(Planner.fetchBaseKey(wellknownService)).isEmpty(); + } - Mockito.when(wellknownService.getWellKnownConfigurationBlocking(Mockito.any(), Mockito.anyMap())) + @Test + void autoconfigureUsesFetchedBaseKeyForUnmappedAttribute() { + var wellknownService = wellknownService(baseKeyConfiguration(structuredBaseKey("ec:secp256r1"))); + var kas = Mockito.mock(SDK.KAS.class); + var resolvedKey = new Config.KASInfo(); + resolvedKey.URL = "https://example.com/base_key"; + resolvedKey.KID = "thekid"; + resolvedKey.Algorithm = "ec:secp256r1"; + resolvedKey.PublicKey = "thepem"; + Mockito.when(kas.getPublicKey(Mockito.any())).thenReturn(resolvedKey); + var services = new FakeServicesBuilder().setWellknownService(wellknownService).setKas(kas).build(); + var config = configWithUnmappedAttribute(); + // A different configured default makes loss of the base key's algorithm observable. + config.wrappingKeyType = KeyType.RSA2048Key; + var planner = new Planner(config, services, Autoconfigure::createGranter); + + var splits = planner.getSplits(); + + Assertions.assertThat(splits).containsOnlyKeys(""); + Assertions.assertThat(splits.get("")).containsExactly(resolvedKey); + var request = ArgumentCaptor.forClass(Config.KASInfo.class); + Mockito.verify(kas).getPublicKey(request.capture()); + assertThat(request.getValue().URL).isEqualTo("https://example.com/base_key"); + assertThat(request.getValue().KID).isEqualTo("thekid"); + assertThat(request.getValue().Algorithm).isEqualTo("ec:secp256r1"); + Mockito.verify(wellknownService).getWellKnownConfigurationBlocking(Mockito.any(), Mockito.anyMap()); + } + + @Test + void autoconfigureFailsWithoutBaseKeyOrDefaultKasForUnmappedAttribute() { + var kas = Mockito.mock(SDK.KAS.class); + var services = new FakeServicesBuilder() + .setWellknownService(wellknownService(Struct.getDefaultInstance())) + .setKas(kas) + .build(); + var planner = new Planner(configWithUnmappedAttribute(), services, Autoconfigure::createGranter); + + assertThatThrownBy(planner::getSplits) + .isInstanceOf(AutoConfigureException.class) + .hasMessage("no default KAS specified; required for grantless plans"); + Mockito.verify(kas, Mockito.never()).getPublicKey(Mockito.any()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALGORITHM_UNSPECIFIED", "UNRECOGNIZED"}) + void autoconfigureRejectsLegacyAlgorithmSentinelsBeforeDefaultKasFallback(String algorithm) { + var json = "{\"kas_url\":\"https://example.com/base_key\"," + + "\"public_key\":{\"algorithm\":\"" + algorithm + "\",\"kid\":\"thekid\",\"pem\":\"thepem\"}}"; + var configuration = Struct.newBuilder() + .putFields("base_key", Value.newBuilder().setStringValue(json).build()) + .build(); + var kas = Mockito.mock(SDK.KAS.class); + var services = new FakeServicesBuilder() + .setWellknownService(wellknownService(configuration)) + .setKas(kas) + .build(); + var config = configWithUnmappedAttribute(); + var defaultKas = new Config.KASInfo(); + defaultKas.URL = "https://fallback.example.com/kas"; + defaultKas.Default = true; + config.kasInfoList.add(defaultKas); + var planner = new Planner(config, services, Autoconfigure::createGranter); + + assertThatThrownBy(planner::getSplits) + .isInstanceOf(SDKException.class) + .hasMessageContaining("unsupported algorithm"); + Mockito.verify(kas, Mockito.never()).getPublicKey(Mockito.any()); + } + + private static Config.TDFConfig configWithUnmappedAttribute() { + var attribute = Attribute.newBuilder() + .setFqn("https://example.com/attr/classification") + .setRule(AttributeRuleTypeEnum.ATTRIBUTE_RULE_TYPE_ENUM_ANY_OF) + .build(); + var value = io.opentdf.platform.policy.Value.newBuilder() + .setFqn("https://example.com/attr/classification/value/internal") + .setValue("internal") + .setAttribute(attribute) + .build(); + var config = new Config.TDFConfig(); + config.autoconfigure = true; + config.attributeValues = List.of(value); + return config; + } + + private static WellKnownServiceClientInterface wellknownService(Struct configuration) { + var service = Mockito.mock(WellKnownServiceClientInterface.class); + var response = GetWellKnownConfigurationResponse.newBuilder().setConfiguration(configuration).build(); + Mockito.when(service.getWellKnownConfigurationBlocking(Mockito.any(), Mockito.anyMap())) .thenReturn(TestUtil.successfulUnaryCall(response)); + return service; + } - var baseKey = Planner.fetchBaseKey(wellknownService); - assertThat(baseKey).isEmpty(); + private static Struct baseKeyConfiguration(Struct baseKey) { + return Struct.newBuilder() + .putFields("base_key", Value.newBuilder().setStructValue(baseKey).build()) + .build(); + } + + private static Struct structuredBaseKey(String algorithm) { + var publicKey = Struct.newBuilder() + .putFields("algorithm", Value.newBuilder().setStringValue(algorithm).build()) + .putFields("kid", Value.newBuilder().setStringValue("thekid").build()) + .putFields("pem", Value.newBuilder().setStringValue("thepem").build()) + .build(); + return Struct.newBuilder() + .putFields("kas_uri", Value.newBuilder().setStringValue("https://example.com/base_key").build()) + .putFields("public_key", Value.newBuilder().setStructValue(publicKey).build()) + .build(); + } + + private static Struct replaceBaseKeyField(Struct baseKey, String field, Value replacement) { + var builder = baseKey.toBuilder(); + if (field.startsWith("public_key.")) { + var publicKey = baseKey.getFieldsOrThrow("public_key").getStructValue().toBuilder(); + var name = field.substring("public_key.".length()); + if (replacement == null) { + publicKey.removeFields(name); + } else { + publicKey.putFields(name, replacement); + } + builder.putFields("public_key", Value.newBuilder().setStructValue(publicKey).build()); + } else if (replacement == null) { + builder.removeFields(field); + } else { + builder.putFields(field, replacement); + } + return builder.build(); } @Test diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java index 138bd5358..289f04bc9 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/SDKTest.java @@ -172,8 +172,16 @@ void testReadingProtocolClient() { void testGettingBaseKey() { var platformServicesClient = mock(ProtocolClient.class); var wellknownService = Mockito.mock(WellKnownServiceClientInterface.class); - var baseKeyJson = "{\"kas_url\":\"https://example.com/base_key\",\"public_key\":{\"algorithm\":\"ALGORITHM_RSA_2048\",\"kid\":\"thekid\",\"pem\": \"thepem\"}}"; - var val = Value.newBuilder().setStringValue(baseKeyJson).build(); + var publicKey = Struct.newBuilder() + .putFields("algorithm", Value.newBuilder().setStringValue("rsa:2048").build()) + .putFields("kid", Value.newBuilder().setStringValue("thekid").build()) + .putFields("pem", Value.newBuilder().setStringValue("thepem").build()) + .build(); + var baseKeyStruct = Struct.newBuilder() + .putFields("kas_uri", Value.newBuilder().setStringValue("https://example.com/base_key").build()) + .putFields("public_key", Value.newBuilder().setStructValue(publicKey).build()) + .build(); + var val = Value.newBuilder().setStructValue(baseKeyStruct).build(); var config = Struct.newBuilder().putFields("base_key", val).build(); var response = GetWellKnownConfigurationResponse .newBuilder() From dfb17018edcbb8105b579ef1245bf3d6e644c70c Mon Sep 17 00:00:00 2001 From: Oleksandr Bernard Date: Wed, 4 Nov 2026 13:38:50 +0000 Subject: [PATCH 2/4] fix(sdk): validate legacy base key field types Signed-off-by: Oleksandr Bernard --- .../java/io/opentdf/platform/sdk/Planner.java | 69 ++++++++++- .../io/opentdf/platform/sdk/PlannerTest.java | 117 ++++++++++++++++-- 2 files changed, 177 insertions(+), 9 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java b/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java index 55cc8636e..e940ef35b 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java @@ -2,6 +2,8 @@ import com.connectrpc.ConnectException; import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; import com.google.gson.annotations.SerializedName; import com.google.protobuf.Struct; @@ -86,6 +88,12 @@ List generatePlanFromProvidedKases(List fetchBaseKey(WellKnownServiceClientInterface wellknown) { var responseMessage = wellknown .getWellKnownConfigurationBlocking(GetWellKnownConfigurationRequest.getDefaultInstance(), Collections.emptyMap()) @@ -109,7 +117,7 @@ static Optional fetchBaseKey(WellKnownServiceClientInterface wellk } else if (baseKeyValue.getKindCase() == Value.KindCase.STRING_VALUE) { // Preserve the JSON-string representation accepted by earlier SDK versions. try { - baseKey = gson.fromJson(baseKeyValue.getStringValue(), BaseKey.class); + baseKey = baseKeyFromLegacyJson(baseKeyValue.getStringValue()); } catch (JsonSyntaxException e) { throw new SDKException("base key in well known configuration is malformed", e); } @@ -152,6 +160,58 @@ static Optional fetchBaseKey(WellKnownServiceClientInterface wellk .build()); } + /** + * Reads legacy fields without allowing Gson to coerce numbers or booleans into strings. + * Missing fields remain subject to the common required-field validation. + * + * @return the decoded fields, or null for absent input or non-string primitive field values + * @throws JsonSyntaxException for malformed JSON or incompatible object, array, or scalar shapes + */ + private static BaseKey baseKeyFromLegacyJson(String json) { + var element = gson.fromJson(json, JsonElement.class); + if (element == null || element.isJsonNull()) { + return null; + } + if (!element.isJsonObject()) { + throw new JsonSyntaxException("expected an object for the legacy base key"); + } + var fields = element.getAsJsonObject(); + var valid = hasLegacyStringFields(fields, "kas_uri", "kas_url"); + var publicKey = fields.get("public_key"); + if (publicKey != null && !publicKey.isJsonNull()) { + if (!publicKey.isJsonObject()) { + throw new JsonSyntaxException("expected an object for the legacy public key"); + } + valid &= hasLegacyStringFields(publicKey.getAsJsonObject(), "algorithm", "kid", "pem"); + } + return valid ? gson.fromJson(json, BaseKey.class) : null; + } + + /** + * Checks every named field before DTO conversion, allowing missing and null values. + * Structural errors retain the legacy exception behavior even if another field is invalid. + * + * @return false if a field contains a number or boolean + * @throws JsonSyntaxException if a field expected to hold a string contains an object or array + */ + private static boolean hasLegacyStringFields(JsonObject fields, String... names) { + var valid = true; + for (var name : names) { + var value = fields.get(name); + if (value == null || value.isJsonNull()) { + continue; + } + if (!value.isJsonPrimitive()) { + throw new JsonSyntaxException("expected a string for legacy base key field: " + name); + } + valid &= value.getAsJsonPrimitive().isString(); + } + return valid; + } + + /** + * Extracts structured fields without coercion, leaving absent or wrongly typed fields unset. + */ private static BaseKey baseKeyFromStruct(Struct fields) { var baseKey = new BaseKey(); baseKey.kasUri = stringField(fields, "kas_uri"); @@ -166,16 +226,23 @@ private static BaseKey baseKeyFromStruct(Struct fields) { return baseKey; } + /** Returns the named protobuf string value, or null when the field is absent or has another type. */ private static String stringField(Struct fields, String name) { var value = fields.getFieldsMap().get(name); return value != null && value.getKindCase() == Value.KindCase.STRING_VALUE ? value.getStringValue() : null; } + /** Treats null and blank strings as missing required field values. */ private static boolean isMissing(String value) { return value == null || value.isBlank(); } + /** + * Resolves supported platform algorithm names and legacy enum names. + * + * @throws IllegalArgumentException if the value does not identify a supported key type + */ private static Algorithm baseKeyAlgorithm(String value) { if (value.startsWith("ALGORITHM_")) { var algorithm = Algorithm.valueOf(value); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java index 57df68f17..cb1262d0a 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java @@ -1,5 +1,6 @@ package io.opentdf.platform.sdk; +import com.google.gson.JsonParser; import com.google.protobuf.ListValue; import com.google.protobuf.NullValue; import com.google.protobuf.Struct; @@ -55,13 +56,10 @@ void fetchBaseKeyFromStructuredConfiguration(String wireAlgorithm, Algorithm exp assertThat(simpleKasKey.getPublicKey().getPem()).isEqualTo("thepem"); } - @Test - void fetchBaseKeyFromLegacyJsonString() { - var json = "{\"kas_url\":\"https://example.com/base_key\"," - + "\"public_key\":{\"algorithm\":\"ALGORITHM_RSA_2048\",\"kid\":\"thekid\",\"pem\":\"thepem\"}}"; - var configuration = Struct.newBuilder() - .putFields("base_key", Value.newBuilder().setStringValue(json).build()) - .build(); + @ParameterizedTest + @ValueSource(strings = {"kas_uri", "kas_url"}) + void fetchBaseKeyFromLegacyJsonString(String kasUriField) { + var configuration = legacyBaseKeyConfiguration(legacyBaseKeyJson(kasUriField)); var baseKey = Planner.fetchBaseKey(wellknownService(configuration)); @@ -72,6 +70,83 @@ void fetchBaseKeyFromLegacyJsonString() { assertThat(baseKey.get().getPublicKey().getPem()).isEqualTo("thepem"); } + @ParameterizedTest + @CsvSource({ + "kas_uri, 123", "kas_uri, true", + "kas_url, 123", "kas_url, true", + "public_key.algorithm, 123", "public_key.algorithm, true", + "public_key.kid, 123", "public_key.kid, true", + "public_key.pem, 123", "public_key.pem, true" + }) + void fetchBaseKeyRejectsNonStringLegacyField(String field, String jsonValue) { + var configuration = legacyBaseKeyConfiguration(legacyBaseKeyWithField(field, jsonValue)); + + assertThat(Planner.fetchBaseKey(wellknownService(configuration))).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "null", "{}", "{\"public_key\":null}"}) + void fetchBaseKeyWithEmptyLegacyConfiguration(String json) { + assertThat(Planner.fetchBaseKey(wellknownService(legacyBaseKeyConfiguration(json)))).isEmpty(); + } + + @Test + void fetchBaseKeyPreservesLegacyAliasOrder() { + var json = "{\"kas_uri\":\"https://first.example.com/kas\"," + + "\"kas_url\":\"https://second.example.com/kas\"," + + legacyBaseKeyJson("kas_uri").substring(1); + + var baseKey = Planner.fetchBaseKey(wellknownService(legacyBaseKeyConfiguration(json))); + + assertThat(baseKey).isPresent(); + assertThat(baseKey.get().getKasUri()).isEqualTo("https://example.com/base_key"); + } + + @ParameterizedTest + @ValueSource(strings = {"123", "true", "[]", "\"base-key\""}) + void fetchBaseKeyWithNonObjectLegacyRootStillThrows(String json) { + var service = wellknownService(legacyBaseKeyConfiguration(json)); + + assertThatThrownBy(() -> Planner.fetchBaseKey(service)) + .isInstanceOf(SDKException.class) + .hasMessageContaining("malformed"); + } + + @ParameterizedTest + @ValueSource(strings = {"123", "true", "[]", "\"public-key\""}) + void fetchBaseKeyWithNonObjectLegacyPublicKeyStillThrows(String jsonValue) { + var configuration = legacyBaseKeyConfiguration(legacyBaseKeyWithField("public_key", jsonValue)); + var service = wellknownService(configuration); + + assertThatThrownBy(() -> Planner.fetchBaseKey(service)) + .isInstanceOf(SDKException.class) + .hasMessageContaining("malformed"); + } + + @ParameterizedTest + @CsvSource({ + "kas_uri, {}", "kas_url, []", "public_key.algorithm, {}", + "public_key.kid, []", "public_key.pem, {}" + }) + void fetchBaseKeyWithStructuredLegacyStringFieldStillThrows(String field, String jsonValue) { + var configuration = legacyBaseKeyConfiguration(legacyBaseKeyWithField(field, jsonValue)); + var service = wellknownService(configuration); + + assertThatThrownBy(() -> Planner.fetchBaseKey(service)) + .isInstanceOf(SDKException.class) + .hasMessageContaining("malformed"); + } + + @Test + void fetchBaseKeyWithInvalidLegacyPrimitiveDoesNotHideMalformedPublicKey() { + var configuration = legacyBaseKeyConfiguration("{\"kas_uri\":123,\"public_key\":[]}"); + var service = wellknownService(configuration); + + assertThatThrownBy(() -> Planner.fetchBaseKey(service)) + .isInstanceOf(SDKException.class) + .hasMessageContaining("malformed"); + } + @Test void fetchBaseKeyWithMalformedLegacyJsonStillThrows() { var configuration = Struct.newBuilder() @@ -239,6 +314,31 @@ private static Struct baseKeyConfiguration(Struct baseKey) { .build(); } + private static Struct legacyBaseKeyConfiguration(String json) { + return Struct.newBuilder() + .putFields("base_key", Value.newBuilder().setStringValue(json).build()) + .build(); + } + + private static String legacyBaseKeyJson(String kasUriField) { + return "{\"" + kasUriField + "\":\"https://example.com/base_key\"," + + "\"public_key\":{\"algorithm\":\"ALGORITHM_RSA_2048\",\"kid\":\"thekid\",\"pem\":\"thepem\"}}"; + } + + /** Replaces one legacy JSON field with a typed value, choosing the URI alias under test. */ + private static String legacyBaseKeyWithField(String field, String jsonValue) { + var baseKey = JsonParser.parseString(legacyBaseKeyJson("kas_url".equals(field) ? "kas_url" : "kas_uri")) + .getAsJsonObject(); + var fields = baseKey; + var name = field; + if (field.startsWith("public_key.")) { + fields = baseKey.getAsJsonObject("public_key"); + name = field.substring("public_key.".length()); + } + fields.add(name, JsonParser.parseString(jsonValue)); + return baseKey.toString(); + } + private static Struct structuredBaseKey(String algorithm) { var publicKey = Struct.newBuilder() .putFields("algorithm", Value.newBuilder().setStringValue(algorithm).build()) @@ -251,6 +351,7 @@ private static Struct structuredBaseKey(String algorithm) { .build(); } + /** Replaces a top-level or nested public-key field; a null replacement removes the field. */ private static Struct replaceBaseKeyField(Struct baseKey, String field, Value replacement) { var builder = baseKey.toBuilder(); if (field.startsWith("public_key.")) { @@ -462,4 +563,4 @@ void usesProvidedSplitPlanWhenNotAutoconfigure() { Assertions.assertThat(splits.get("split1")).extracting("URL").containsExactly("https://kas1.example.com"); Assertions.assertThat(splits.get("split2")).extracting("URL").containsExactly("https://kas2.example.com"); } -} \ No newline at end of file +} From 055acbe9762580e43270b93d1a823846ee93212f Mon Sep 17 00:00:00 2001 From: Oleksandr Bernard Date: Wed, 4 Nov 2026 17:55:55 +0000 Subject: [PATCH 3/4] fix(sdk): remove legacy base key parsing Signed-off-by: Oleksandr Bernard --- .../java/io/opentdf/platform/sdk/Planner.java | 141 +++------------ .../io/opentdf/platform/sdk/PlannerTest.java | 162 +----------------- 2 files changed, 25 insertions(+), 278 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java b/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java index e940ef35b..422c96d15 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java @@ -1,11 +1,6 @@ package io.opentdf.platform.sdk; import com.connectrpc.ConnectException; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.JsonSyntaxException; -import com.google.gson.annotations.SerializedName; import com.google.protobuf.Struct; import com.google.protobuf.Value; import io.opentdf.platform.policy.Algorithm; @@ -89,10 +84,10 @@ List generatePlanFromProvidedKases(List fetchBaseKey(WellKnownServiceClientInterface wellknown) { var responseMessage = wellknown @@ -111,121 +106,47 @@ static Optional fetchBaseKey(WellKnownServiceClientInterface wellk return Optional.empty(); } - BaseKey baseKey; - if (baseKeyValue.getKindCase() == Value.KindCase.STRUCT_VALUE) { - baseKey = baseKeyFromStruct(baseKeyValue.getStructValue()); - } else if (baseKeyValue.getKindCase() == Value.KindCase.STRING_VALUE) { - // Preserve the JSON-string representation accepted by earlier SDK versions. - try { - baseKey = baseKeyFromLegacyJson(baseKeyValue.getStringValue()); - } catch (JsonSyntaxException e) { - throw new SDKException("base key in well known configuration is malformed", e); - } - } else { + if (baseKeyValue.getKindCase() != Value.KindCase.STRUCT_VALUE) { logger.error("base key in well known configuration has an invalid type; base key will not be used"); return Optional.empty(); } - if (baseKey == null || isMissing(baseKey.kasUri) || baseKey.publicKey == null - || isMissing(baseKey.publicKey.kid) || isMissing(baseKey.publicKey.pem) - || isMissing(baseKey.publicKey.algorithm)) { + var baseKey = baseKeyValue.getStructValue(); + var publicKeyValue = baseKey.getFieldsMap().get("public_key"); + if (publicKeyValue == null || publicKeyValue.getKindCase() != Value.KindCase.STRUCT_VALUE) { logger.error("base key in well known configuration is missing valid required fields; base key will not be used"); return Optional.empty(); } - if (baseKeyValue.getKindCase() == Value.KindCase.STRING_VALUE - && ("ALGORITHM_UNSPECIFIED".equals(baseKey.publicKey.algorithm) - || "UNRECOGNIZED".equals(baseKey.publicKey.algorithm))) { - // These legacy enum values failed during planning or key construction. - // Do not turn them into an absent key that allows default-KAS fallback. - throw new SDKException("base key in well known configuration has an unsupported algorithm"); + var publicKey = publicKeyValue.getStructValue(); + var kasUri = stringField(baseKey, "kas_uri"); + var kid = stringField(publicKey, "kid"); + var pem = stringField(publicKey, "pem"); + var algorithmName = stringField(publicKey, "algorithm"); + if (isMissing(kasUri) || isMissing(kid) || isMissing(pem) || isMissing(algorithmName)) { + logger.error("base key in well known configuration is missing valid required fields; base key will not be used"); + return Optional.empty(); } Algorithm algorithm; try { - algorithm = baseKeyAlgorithm(baseKey.publicKey.algorithm); + algorithm = baseKeyAlgorithm(algorithmName); } catch (IllegalArgumentException e) { logger.error("base key in well known configuration has an unsupported algorithm; base key will not be used"); return Optional.empty(); } return Optional.of(SimpleKasKey.newBuilder() - .setKasUri(baseKey.kasUri) + .setKasUri(kasUri) .setPublicKey( SimpleKasPublicKey.newBuilder() - .setKid(baseKey.publicKey.kid) + .setKid(kid) .setAlgorithm(algorithm) - .setPem(baseKey.publicKey.pem) + .setPem(pem) .build()) .build()); } - /** - * Reads legacy fields without allowing Gson to coerce numbers or booleans into strings. - * Missing fields remain subject to the common required-field validation. - * - * @return the decoded fields, or null for absent input or non-string primitive field values - * @throws JsonSyntaxException for malformed JSON or incompatible object, array, or scalar shapes - */ - private static BaseKey baseKeyFromLegacyJson(String json) { - var element = gson.fromJson(json, JsonElement.class); - if (element == null || element.isJsonNull()) { - return null; - } - if (!element.isJsonObject()) { - throw new JsonSyntaxException("expected an object for the legacy base key"); - } - var fields = element.getAsJsonObject(); - var valid = hasLegacyStringFields(fields, "kas_uri", "kas_url"); - var publicKey = fields.get("public_key"); - if (publicKey != null && !publicKey.isJsonNull()) { - if (!publicKey.isJsonObject()) { - throw new JsonSyntaxException("expected an object for the legacy public key"); - } - valid &= hasLegacyStringFields(publicKey.getAsJsonObject(), "algorithm", "kid", "pem"); - } - return valid ? gson.fromJson(json, BaseKey.class) : null; - } - - /** - * Checks every named field before DTO conversion, allowing missing and null values. - * Structural errors retain the legacy exception behavior even if another field is invalid. - * - * @return false if a field contains a number or boolean - * @throws JsonSyntaxException if a field expected to hold a string contains an object or array - */ - private static boolean hasLegacyStringFields(JsonObject fields, String... names) { - var valid = true; - for (var name : names) { - var value = fields.get(name); - if (value == null || value.isJsonNull()) { - continue; - } - if (!value.isJsonPrimitive()) { - throw new JsonSyntaxException("expected a string for legacy base key field: " + name); - } - valid &= value.getAsJsonPrimitive().isString(); - } - return valid; - } - - /** - * Extracts structured fields without coercion, leaving absent or wrongly typed fields unset. - */ - private static BaseKey baseKeyFromStruct(Struct fields) { - var baseKey = new BaseKey(); - baseKey.kasUri = stringField(fields, "kas_uri"); - var publicKey = fields.getFieldsMap().get("public_key"); - if (publicKey != null && publicKey.getKindCase() == Value.KindCase.STRUCT_VALUE) { - var keyFields = publicKey.getStructValue(); - baseKey.publicKey = new BaseKey.Key(); - baseKey.publicKey.kid = stringField(keyFields, "kid"); - baseKey.publicKey.pem = stringField(keyFields, "pem"); - baseKey.publicKey.algorithm = stringField(keyFields, "algorithm"); - } - return baseKey; - } - /** Returns the named protobuf string value, or null when the field is absent or has another type. */ private static String stringField(Struct fields, String name) { var value = fields.getFieldsMap().get(name); @@ -239,17 +160,11 @@ private static boolean isMissing(String value) { } /** - * Resolves supported platform algorithm names and legacy enum names. + * Resolves the platform's wire-format algorithm names to supported policy algorithms. * * @throws IllegalArgumentException if the value does not identify a supported key type */ private static Algorithm baseKeyAlgorithm(String value) { - if (value.startsWith("ALGORITHM_")) { - var algorithm = Algorithm.valueOf(value); - KeyType.fromAlgorithm(algorithm); - return algorithm; - } - switch (KeyType.fromString(value)) { case RSA2048Key: return Algorithm.ALGORITHM_RSA_2048; @@ -276,22 +191,6 @@ private static Algorithm baseKeyAlgorithm(String value) { } } - private static final Gson gson = new Gson(); - - private static class BaseKey { - @SerializedName(value = "kas_uri", alternate = {"kas_url"}) - String kasUri; - - @SerializedName("public_key") - Key publicKey; - - private static class Key { - String kid; - String pem; - String algorithm; - } - } - Map> resolveKeys(List splitPlan) { Map> conjunction = new HashMap<>(); var latestKASInfo = new HashMap(); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java index cb1262d0a..8d9753df3 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/PlannerTest.java @@ -1,6 +1,5 @@ package io.opentdf.platform.sdk; -import com.google.gson.JsonParser; import com.google.protobuf.ListValue; import com.google.protobuf.NullValue; import com.google.protobuf.Struct; @@ -56,109 +55,6 @@ void fetchBaseKeyFromStructuredConfiguration(String wireAlgorithm, Algorithm exp assertThat(simpleKasKey.getPublicKey().getPem()).isEqualTo("thepem"); } - @ParameterizedTest - @ValueSource(strings = {"kas_uri", "kas_url"}) - void fetchBaseKeyFromLegacyJsonString(String kasUriField) { - var configuration = legacyBaseKeyConfiguration(legacyBaseKeyJson(kasUriField)); - - var baseKey = Planner.fetchBaseKey(wellknownService(configuration)); - - assertThat(baseKey).isPresent(); - assertThat(baseKey.get().getKasUri()).isEqualTo("https://example.com/base_key"); - assertThat(baseKey.get().getPublicKey().getAlgorithm()).isEqualTo(Algorithm.ALGORITHM_RSA_2048); - assertThat(baseKey.get().getPublicKey().getKid()).isEqualTo("thekid"); - assertThat(baseKey.get().getPublicKey().getPem()).isEqualTo("thepem"); - } - - @ParameterizedTest - @CsvSource({ - "kas_uri, 123", "kas_uri, true", - "kas_url, 123", "kas_url, true", - "public_key.algorithm, 123", "public_key.algorithm, true", - "public_key.kid, 123", "public_key.kid, true", - "public_key.pem, 123", "public_key.pem, true" - }) - void fetchBaseKeyRejectsNonStringLegacyField(String field, String jsonValue) { - var configuration = legacyBaseKeyConfiguration(legacyBaseKeyWithField(field, jsonValue)); - - assertThat(Planner.fetchBaseKey(wellknownService(configuration))).isEmpty(); - } - - @ParameterizedTest - @ValueSource(strings = {"", " ", "null", "{}", "{\"public_key\":null}"}) - void fetchBaseKeyWithEmptyLegacyConfiguration(String json) { - assertThat(Planner.fetchBaseKey(wellknownService(legacyBaseKeyConfiguration(json)))).isEmpty(); - } - - @Test - void fetchBaseKeyPreservesLegacyAliasOrder() { - var json = "{\"kas_uri\":\"https://first.example.com/kas\"," - + "\"kas_url\":\"https://second.example.com/kas\"," - + legacyBaseKeyJson("kas_uri").substring(1); - - var baseKey = Planner.fetchBaseKey(wellknownService(legacyBaseKeyConfiguration(json))); - - assertThat(baseKey).isPresent(); - assertThat(baseKey.get().getKasUri()).isEqualTo("https://example.com/base_key"); - } - - @ParameterizedTest - @ValueSource(strings = {"123", "true", "[]", "\"base-key\""}) - void fetchBaseKeyWithNonObjectLegacyRootStillThrows(String json) { - var service = wellknownService(legacyBaseKeyConfiguration(json)); - - assertThatThrownBy(() -> Planner.fetchBaseKey(service)) - .isInstanceOf(SDKException.class) - .hasMessageContaining("malformed"); - } - - @ParameterizedTest - @ValueSource(strings = {"123", "true", "[]", "\"public-key\""}) - void fetchBaseKeyWithNonObjectLegacyPublicKeyStillThrows(String jsonValue) { - var configuration = legacyBaseKeyConfiguration(legacyBaseKeyWithField("public_key", jsonValue)); - var service = wellknownService(configuration); - - assertThatThrownBy(() -> Planner.fetchBaseKey(service)) - .isInstanceOf(SDKException.class) - .hasMessageContaining("malformed"); - } - - @ParameterizedTest - @CsvSource({ - "kas_uri, {}", "kas_url, []", "public_key.algorithm, {}", - "public_key.kid, []", "public_key.pem, {}" - }) - void fetchBaseKeyWithStructuredLegacyStringFieldStillThrows(String field, String jsonValue) { - var configuration = legacyBaseKeyConfiguration(legacyBaseKeyWithField(field, jsonValue)); - var service = wellknownService(configuration); - - assertThatThrownBy(() -> Planner.fetchBaseKey(service)) - .isInstanceOf(SDKException.class) - .hasMessageContaining("malformed"); - } - - @Test - void fetchBaseKeyWithInvalidLegacyPrimitiveDoesNotHideMalformedPublicKey() { - var configuration = legacyBaseKeyConfiguration("{\"kas_uri\":123,\"public_key\":[]}"); - var service = wellknownService(configuration); - - assertThatThrownBy(() -> Planner.fetchBaseKey(service)) - .isInstanceOf(SDKException.class) - .hasMessageContaining("malformed"); - } - - @Test - void fetchBaseKeyWithMalformedLegacyJsonStillThrows() { - var configuration = Struct.newBuilder() - .putFields("base_key", Value.newBuilder().setStringValue("{").build()) - .build(); - var service = wellknownService(configuration); - - assertThatThrownBy(() -> Planner.fetchBaseKey(service)) - .isInstanceOf(SDKException.class) - .hasMessageContaining("malformed"); - } - @Test void fetchBaseKeyWithNoBaseKey() { assertThat(Planner.fetchBaseKey(wellknownService(Struct.getDefaultInstance()))).isEmpty(); @@ -204,11 +100,14 @@ private static Stream invalidBaseKeyValues() { Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(), Value.newBuilder().setNumberValue(123).build(), Value.newBuilder().setBoolValue(true).build(), - Value.newBuilder().setListValue(ListValue.getDefaultInstance()).build()); + Value.newBuilder().setListValue(ListValue.getDefaultInstance()).build(), + Value.newBuilder().setStringValue("{\"kas_uri\":\"https://example.com/base_key\"," + + "\"public_key\":{\"algorithm\":\"rsa:2048\",\"kid\":\"thekid\",\"pem\":\"thepem\"}}") + .build()); } @ParameterizedTest - @ValueSource(strings = {"rsa:1024", "unknown", "ALGORITHM_UNSPECIFIED", "UNRECOGNIZED"}) + @ValueSource(strings = {"rsa:1024", "unknown", "ALGORITHM_RSA_2048", "ALGORITHM_UNSPECIFIED", "UNRECOGNIZED"}) void fetchBaseKeyRejectsUnsupportedAlgorithm(String algorithm) { var wellknownService = wellknownService(baseKeyConfiguration(structuredBaseKey(algorithm))); @@ -258,32 +157,6 @@ void autoconfigureFailsWithoutBaseKeyOrDefaultKasForUnmappedAttribute() { Mockito.verify(kas, Mockito.never()).getPublicKey(Mockito.any()); } - @ParameterizedTest - @ValueSource(strings = {"ALGORITHM_UNSPECIFIED", "UNRECOGNIZED"}) - void autoconfigureRejectsLegacyAlgorithmSentinelsBeforeDefaultKasFallback(String algorithm) { - var json = "{\"kas_url\":\"https://example.com/base_key\"," - + "\"public_key\":{\"algorithm\":\"" + algorithm + "\",\"kid\":\"thekid\",\"pem\":\"thepem\"}}"; - var configuration = Struct.newBuilder() - .putFields("base_key", Value.newBuilder().setStringValue(json).build()) - .build(); - var kas = Mockito.mock(SDK.KAS.class); - var services = new FakeServicesBuilder() - .setWellknownService(wellknownService(configuration)) - .setKas(kas) - .build(); - var config = configWithUnmappedAttribute(); - var defaultKas = new Config.KASInfo(); - defaultKas.URL = "https://fallback.example.com/kas"; - defaultKas.Default = true; - config.kasInfoList.add(defaultKas); - var planner = new Planner(config, services, Autoconfigure::createGranter); - - assertThatThrownBy(planner::getSplits) - .isInstanceOf(SDKException.class) - .hasMessageContaining("unsupported algorithm"); - Mockito.verify(kas, Mockito.never()).getPublicKey(Mockito.any()); - } - private static Config.TDFConfig configWithUnmappedAttribute() { var attribute = Attribute.newBuilder() .setFqn("https://example.com/attr/classification") @@ -314,31 +187,6 @@ private static Struct baseKeyConfiguration(Struct baseKey) { .build(); } - private static Struct legacyBaseKeyConfiguration(String json) { - return Struct.newBuilder() - .putFields("base_key", Value.newBuilder().setStringValue(json).build()) - .build(); - } - - private static String legacyBaseKeyJson(String kasUriField) { - return "{\"" + kasUriField + "\":\"https://example.com/base_key\"," - + "\"public_key\":{\"algorithm\":\"ALGORITHM_RSA_2048\",\"kid\":\"thekid\",\"pem\":\"thepem\"}}"; - } - - /** Replaces one legacy JSON field with a typed value, choosing the URI alias under test. */ - private static String legacyBaseKeyWithField(String field, String jsonValue) { - var baseKey = JsonParser.parseString(legacyBaseKeyJson("kas_url".equals(field) ? "kas_url" : "kas_uri")) - .getAsJsonObject(); - var fields = baseKey; - var name = field; - if (field.startsWith("public_key.")) { - fields = baseKey.getAsJsonObject("public_key"); - name = field.substring("public_key.".length()); - } - fields.add(name, JsonParser.parseString(jsonValue)); - return baseKey.toString(); - } - private static Struct structuredBaseKey(String algorithm) { var publicKey = Struct.newBuilder() .putFields("algorithm", Value.newBuilder().setStringValue(algorithm).build()) From ddb6a5acf06959075cbe448521b83cba7323ec34 Mon Sep 17 00:00:00 2001 From: Oleksandr Bernard Date: Fri, 6 Nov 2026 13:42:28 +0000 Subject: [PATCH 4/4] refactor(sdk): centralize key algorithm mappings Signed-off-by: Oleksandr Bernard --- .../java/io/opentdf/platform/sdk/KeyType.java | 69 +++++++++---------- .../java/io/opentdf/platform/sdk/Planner.java | 34 +-------- .../io/opentdf/platform/sdk/KeyTypeTest.java | 41 ++++++++++- 3 files changed, 74 insertions(+), 70 deletions(-) diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java b/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java index 3ecca5f31..1328f4ef1 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/KeyType.java @@ -10,52 +10,60 @@ import static io.opentdf.platform.sdk.ECCurve.SECP521R1; public enum KeyType { - RSA2048Key("rsa:2048"), - RSA4096Key("rsa:4096"), - EC256Key("ec:secp256r1", SECP256R1), - EC384Key("ec:secp384r1", SECP384R1), - EC521Key("ec:secp521r1", SECP521R1), + RSA2048Key("rsa:2048", Algorithm.ALGORITHM_RSA_2048), + RSA4096Key("rsa:4096", Algorithm.ALGORITHM_RSA_4096), + EC256Key("ec:secp256r1", Algorithm.ALGORITHM_EC_P256, SECP256R1), + EC384Key("ec:secp384r1", Algorithm.ALGORITHM_EC_P384, SECP384R1), + EC521Key("ec:secp521r1", Algorithm.ALGORITHM_EC_P521, SECP521R1), /** * Hybrid X-Wing (X25519 + ML-KEM-768). Requires {@code sdk-pqc-bc} (or another * {@link io.opentdf.platform.sdk.spi.KemProvider} implementation) on the runtime * classpath; not available under the fips Maven profile. */ - HybridXWingKey("hpqt:xwing"), + HybridXWingKey("hpqt:xwing", Algorithm.ALGORITHM_HPQT_XWING), /** * Hybrid P-256 + ML-KEM-768 per draft-ietf-lamps-pq-composite-kem-14. Requires * {@code sdk-pqc-bc} (or another {@link io.opentdf.platform.sdk.spi.KemProvider} * implementation) on the runtime classpath; not available under the fips Maven profile. */ - HybridSecp256r1MLKEM768Key("hpqt:secp256r1-mlkem768"), + HybridSecp256r1MLKEM768Key("hpqt:secp256r1-mlkem768", Algorithm.ALGORITHM_HPQT_SECP256R1_MLKEM768), /** * Hybrid P-384 + ML-KEM-1024 per draft-ietf-lamps-pq-composite-kem-14. Requires * {@code sdk-pqc-bc} (or another {@link io.opentdf.platform.sdk.spi.KemProvider} * implementation) on the runtime classpath; not available under the fips Maven profile. */ - HybridSecp384r1MLKEM1024Key("hpqt:secp384r1-mlkem1024"), + HybridSecp384r1MLKEM1024Key("hpqt:secp384r1-mlkem1024", Algorithm.ALGORITHM_HPQT_SECP384R1_MLKEM1024), /** * Pure ML-KEM-768 (FIPS 203). Requires {@code sdk-pqc-bc} (or another * {@link io.opentdf.platform.sdk.spi.KemProvider} implementation) on the runtime * classpath; not available under the fips Maven profile. */ - MLKEM768Key("mlkem:768"), + MLKEM768Key("mlkem:768", Algorithm.ALGORITHM_MLKEM_768), /** * Pure ML-KEM-1024 (FIPS 203). Requires {@code sdk-pqc-bc} (or another * {@link io.opentdf.platform.sdk.spi.KemProvider} implementation) on the runtime * classpath; not available under the fips Maven profile. */ - MLKEM1024Key("mlkem:1024"); + MLKEM1024Key("mlkem:1024", Algorithm.ALGORITHM_MLKEM_1024); private final String keyType; + private final Algorithm algorithm; private final ECCurve curve; - KeyType(String keyType, ECCurve ecCurve) { + KeyType(String keyType, Algorithm algorithm, ECCurve ecCurve) { this.keyType = keyType; + this.algorithm = algorithm; this.curve = ecCurve; } - KeyType(String keyType) { - this(keyType, null); + KeyType(String keyType, Algorithm algorithm) { + this(keyType, algorithm, null); + } + + /** Returns the policy algorithm represented by this key type. */ + @Nonnull + public Algorithm getAlgorithm() { + return algorithm; } @Nonnull @@ -80,34 +88,23 @@ public static KeyType fromString(String keyType) { throw new IllegalArgumentException("No enum constant for key type: " + keyType); } + /** + * Returns the key type associated with a policy algorithm. + * + * @param algorithm the policy algorithm to convert + * @return the corresponding key type + * @throws IllegalArgumentException if the algorithm is null or unsupported + */ public static KeyType fromAlgorithm(Algorithm algorithm) { if (algorithm == null) { throw new IllegalArgumentException("Algorithm cannot be null"); } - switch (algorithm) { - case ALGORITHM_RSA_2048: - return KeyType.RSA2048Key; - case ALGORITHM_RSA_4096: - return KeyType.RSA4096Key; - case ALGORITHM_EC_P256: - return KeyType.EC256Key; - case ALGORITHM_EC_P384: - return KeyType.EC384Key; - case ALGORITHM_EC_P521: - return KeyType.EC521Key; - case ALGORITHM_HPQT_XWING: - return KeyType.HybridXWingKey; - case ALGORITHM_HPQT_SECP256R1_MLKEM768: - return KeyType.HybridSecp256r1MLKEM768Key; - case ALGORITHM_HPQT_SECP384R1_MLKEM1024: - return KeyType.HybridSecp384r1MLKEM1024Key; - case ALGORITHM_MLKEM_768: - return KeyType.MLKEM768Key; - case ALGORITHM_MLKEM_1024: - return KeyType.MLKEM1024Key; - default: - throw new IllegalArgumentException("Unsupported algorithm: " + algorithm); + for (KeyType type : KeyType.values()) { + if (type.algorithm == algorithm) { + return type; + } } + throw new IllegalArgumentException("Unsupported algorithm: " + algorithm); } public static KeyType fromPublicKeyAlgorithm(KasPublicKeyAlgEnum algorithm) { diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java b/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java index 422c96d15..3499cb137 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/Planner.java @@ -130,7 +130,7 @@ static Optional fetchBaseKey(WellKnownServiceClientInterface wellk Algorithm algorithm; try { - algorithm = baseKeyAlgorithm(algorithmName); + algorithm = KeyType.fromString(algorithmName).getAlgorithm(); } catch (IllegalArgumentException e) { logger.error("base key in well known configuration has an unsupported algorithm; base key will not be used"); return Optional.empty(); @@ -159,38 +159,6 @@ private static boolean isMissing(String value) { return value == null || value.isBlank(); } - /** - * Resolves the platform's wire-format algorithm names to supported policy algorithms. - * - * @throws IllegalArgumentException if the value does not identify a supported key type - */ - private static Algorithm baseKeyAlgorithm(String value) { - switch (KeyType.fromString(value)) { - case RSA2048Key: - return Algorithm.ALGORITHM_RSA_2048; - case RSA4096Key: - return Algorithm.ALGORITHM_RSA_4096; - case EC256Key: - return Algorithm.ALGORITHM_EC_P256; - case EC384Key: - return Algorithm.ALGORITHM_EC_P384; - case EC521Key: - return Algorithm.ALGORITHM_EC_P521; - case HybridXWingKey: - return Algorithm.ALGORITHM_HPQT_XWING; - case HybridSecp256r1MLKEM768Key: - return Algorithm.ALGORITHM_HPQT_SECP256R1_MLKEM768; - case HybridSecp384r1MLKEM1024Key: - return Algorithm.ALGORITHM_HPQT_SECP384R1_MLKEM1024; - case MLKEM768Key: - return Algorithm.ALGORITHM_MLKEM_768; - case MLKEM1024Key: - return Algorithm.ALGORITHM_MLKEM_1024; - default: - throw new IllegalArgumentException("Unsupported base key algorithm: " + value); - } - } - Map> resolveKeys(List splitPlan) { Map> conjunction = new HashMap<>(); var latestKASInfo = new HashMap(); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/KeyTypeTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/KeyTypeTest.java index 980404f12..36b159a1f 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/KeyTypeTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/KeyTypeTest.java @@ -1,10 +1,20 @@ package io.opentdf.platform.sdk; +import io.opentdf.platform.policy.Algorithm; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; import static io.opentdf.platform.policy.Algorithm.ALGORITHM_EC_P256; import static io.opentdf.platform.policy.Algorithm.ALGORITHM_EC_P384; import static io.opentdf.platform.policy.Algorithm.ALGORITHM_EC_P521; +import static io.opentdf.platform.policy.Algorithm.ALGORITHM_HPQT_SECP256R1_MLKEM768; +import static io.opentdf.platform.policy.Algorithm.ALGORITHM_HPQT_SECP384R1_MLKEM1024; +import static io.opentdf.platform.policy.Algorithm.ALGORITHM_HPQT_XWING; +import static io.opentdf.platform.policy.Algorithm.ALGORITHM_MLKEM_1024; +import static io.opentdf.platform.policy.Algorithm.ALGORITHM_MLKEM_768; import static io.opentdf.platform.policy.Algorithm.ALGORITHM_RSA_2048; import static io.opentdf.platform.policy.Algorithm.ALGORITHM_RSA_4096; import static io.opentdf.platform.policy.KasPublicKeyAlgEnum.KAS_PUBLIC_KEY_ALG_ENUM_EC_SECP256R1; @@ -36,6 +46,35 @@ void testFromAlgorithm() { assertEquals(KeyType.EC256Key, KeyType.fromAlgorithm(ALGORITHM_EC_P256)); assertEquals(KeyType.EC384Key, KeyType.fromAlgorithm(ALGORITHM_EC_P384)); assertEquals(KeyType.EC521Key, KeyType.fromAlgorithm(ALGORITHM_EC_P521)); + assertEquals(KeyType.HybridXWingKey, KeyType.fromAlgorithm(ALGORITHM_HPQT_XWING)); + assertEquals(KeyType.HybridSecp256r1MLKEM768Key, KeyType.fromAlgorithm(ALGORITHM_HPQT_SECP256R1_MLKEM768)); + assertEquals(KeyType.HybridSecp384r1MLKEM1024Key, KeyType.fromAlgorithm(ALGORITHM_HPQT_SECP384R1_MLKEM1024)); + assertEquals(KeyType.MLKEM768Key, KeyType.fromAlgorithm(ALGORITHM_MLKEM_768)); + assertEquals(KeyType.MLKEM1024Key, KeyType.fromAlgorithm(ALGORITHM_MLKEM_1024)); + } + + @ParameterizedTest + @NullSource + @EnumSource(value = Algorithm.class, names = {"ALGORITHM_UNSPECIFIED", "UNRECOGNIZED"}) + void testFromAlgorithmInvalid(Algorithm algorithm) { + var failure = assertThrows(IllegalArgumentException.class, () -> KeyType.fromAlgorithm(algorithm)); + + assertEquals(algorithm == null ? "Algorithm cannot be null" : "Unsupported algorithm: " + algorithm, + failure.getMessage()); + } + + @ParameterizedTest + @CsvSource({"EC256Key, SECP256R1", "EC384Key, SECP384R1", "EC521Key, SECP521R1"}) + void testEcCurve(KeyType keyType, ECCurve expectedCurve) { + assertTrue(keyType.isEc()); + assertEquals(expectedCurve, keyType.getECCurve()); + } + + @ParameterizedTest + @EnumSource(value = KeyType.class, names = {"RSA2048Key", "HybridSecp256r1MLKEM768Key"}) + void testNonEcKeyHasNoCurve(KeyType keyType) { + assertFalse(keyType.isEc()); + assertThrows(IllegalStateException.class, keyType::getECCurve); } @Test @@ -46,4 +85,4 @@ void testFromPublicKeyAlgEnum() { assertEquals(KeyType.EC384Key, KeyType.fromPublicKeyAlgorithm(KAS_PUBLIC_KEY_ALG_ENUM_EC_SECP384R1)); assertEquals(KeyType.EC521Key, KeyType.fromPublicKeyAlgorithm(KAS_PUBLIC_KEY_ALG_ENUM_EC_SECP521R1)); } -} \ No newline at end of file +}