diff --git a/CHANGELOG.md b/CHANGELOG.md index b2dc5ab4f2..0a685434ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format `: `. ## SNAPSHOT +* [#1076](https://github.com/kroxylicious/kroxylicious/issues/1076): AWS KMS implementation for Record Encryption * [#1201](https://github.com/kroxylicious/kroxylicious/pull/1201): Bump com.fasterxml.jackson:jackson-bom from 2.17.0 to 2.17.1 * [#1158](https://github.com/kroxylicious/kroxylicious/pull/1158): Bump io.netty:netty-bom from 4.1.108.Final to 4.1.109.Final * [#1162](https://github.com/kroxylicious/kroxylicious/issues/1162): Fix #1162: allow tenant / resource name prefix separator to be controlled from configuration diff --git a/kroxylicious-app/pom.xml b/kroxylicious-app/pom.xml index 9b94722fd8..2b9e49adee 100644 --- a/kroxylicious-app/pom.xml +++ b/kroxylicious-app/pom.xml @@ -240,6 +240,11 @@ kroxylicious-kms-provider-hashicorp-vault runtime + + io.kroxylicious + kroxylicious-kms-provider-aws-kms + runtime + diff --git a/kroxylicious-bom/pom.xml b/kroxylicious-bom/pom.xml index 43fd3c1d6e..e5b6cc042c 100644 --- a/kroxylicious-bom/pom.xml +++ b/kroxylicious-bom/pom.xml @@ -135,6 +135,12 @@ ${project.version} + + io.kroxylicious + kroxylicious-kms-provider-aws-kms + ${project.version} + + io.kroxylicious kroxylicious-record-encryption @@ -185,6 +191,12 @@ ${project.version} + + io.kroxylicious + kroxylicious-kms-provider-aws-kms-test-support + ${project.version} + + io.kroxylicious kroxylicious-filter-test-support diff --git a/kroxylicious-integration-tests/pom.xml b/kroxylicious-integration-tests/pom.xml index 94b4f72c1a..c70e7f862b 100644 --- a/kroxylicious-integration-tests/pom.xml +++ b/kroxylicious-integration-tests/pom.xml @@ -68,6 +68,17 @@ kroxylicious-kms-provider-kroxylicious-inmemory-test-support runtime + + io.kroxylicious + kroxylicious-kms-provider-aws-kms-test-support + runtime + + + org.hamcrest + hamcrest-core + + + io.kroxylicious kroxylicious-kms-provider-hashicorp-vault-test-support diff --git a/kroxylicious-kms-provider-aws-kms-test-support/pom.xml b/kroxylicious-kms-provider-aws-kms-test-support/pom.xml new file mode 100644 index 0000000000..3e0434474f --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/pom.xml @@ -0,0 +1,88 @@ + + + + + 4.0.0 + + + io.kroxylicious + kroxylicious-parent + 0.6.0-SNAPSHOT + ../pom.xml + + + kroxylicious-kms-provider-aws-kms-test-support + + AWS KMS test support + Test support code for modules testing the AWS KMS + + + + + io.kroxylicious + kroxylicious-kms + + + io.kroxylicious + kroxylicious-kms-provider-aws-kms + + + io.kroxylicious + kroxylicious-kms-test-support + + + io.kroxylicious + kroxylicious-api + + + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-annotations + + + org.slf4j + slf4j-api + + + org.testcontainers + testcontainers + + + org.testcontainers + localstack + + + + org.assertj + assertj-core + test + + + org.junit.jupiter + junit-jupiter-api + test + + + com.github.spotbugs + spotbugs-annotations + compile + + + \ No newline at end of file diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AbstractAwsKmsTestKmsFacade.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AbstractAwsKmsTestKmsFacade.java new file mode 100644 index 0000000000..46f5b882bd --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AbstractAwsKmsTestKmsFacade.java @@ -0,0 +1,55 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.net.URI; + +import io.kroxylicious.kms.provider.aws.kms.config.Config; +import io.kroxylicious.kms.service.TestKmsFacade; +import io.kroxylicious.proxy.config.secret.InlinePassword; + +import edu.umd.cs.findbugs.annotations.NonNull; + +public abstract class AbstractAwsKmsTestKmsFacade implements TestKmsFacade { + + protected AbstractAwsKmsTestKmsFacade() { + } + + protected abstract void startKms(); + + protected abstract void stopKms(); + + @Override + public final void start() { + startKms(); + } + + @NonNull + protected abstract URI getAwsUrl(); + + @Override + public final Config getKmsServiceConfig() { + return new Config(getAwsUrl(), new InlinePassword(getAccessKey()), new InlinePassword(getSecretKey()), getRegion(), null); + } + + protected abstract String getRegion(); + + protected abstract String getSecretKey(); + + protected abstract String getAccessKey(); + + @Override + public final Class getKmsServiceClass() { + return AwsKmsService.class; + } + + @Override + public final void stop() { + stopKms(); + } + +} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AbstractAwsKmsTestKmsFacadeFactory.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AbstractAwsKmsTestKmsFacadeFactory.java new file mode 100644 index 0000000000..64d9b7f30b --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AbstractAwsKmsTestKmsFacadeFactory.java @@ -0,0 +1,15 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import io.kroxylicious.kms.provider.aws.kms.config.Config; +import io.kroxylicious.kms.service.TestKmsFacadeFactory; + +public abstract class AbstractAwsKmsTestKmsFacadeFactory implements TestKmsFacadeFactory { + @Override + public abstract AbstractAwsKmsTestKmsFacade build(); +} \ No newline at end of file diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTestKmsFacade.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTestKmsFacade.java new file mode 100644 index 0000000000..ef59672d8d --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTestKmsFacade.java @@ -0,0 +1,311 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.time.Instant; +import java.util.Objects; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.localstack.LocalStackContainer; +import org.testcontainers.utility.DockerImageName; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.kroxylicious.kms.provider.aws.kms.model.CreateAliasRequest; +import io.kroxylicious.kms.provider.aws.kms.model.CreateKeyRequest; +import io.kroxylicious.kms.provider.aws.kms.model.CreateKeyResponse; +import io.kroxylicious.kms.provider.aws.kms.model.DeleteAliasRequest; +import io.kroxylicious.kms.provider.aws.kms.model.DescribeKeyRequest; +import io.kroxylicious.kms.provider.aws.kms.model.DescribeKeyResponse; +import io.kroxylicious.kms.provider.aws.kms.model.ErrorResponse; +import io.kroxylicious.kms.provider.aws.kms.model.ScheduleKeyDeletionRequest; +import io.kroxylicious.kms.provider.aws.kms.model.ScheduleKeyDeletionResponse; +import io.kroxylicious.kms.provider.aws.kms.model.UpdateAliasRequest; +import io.kroxylicious.kms.service.KmsException; +import io.kroxylicious.kms.service.TestKekManager; +import io.kroxylicious.kms.service.UnknownAliasException; + +import edu.umd.cs.findbugs.annotations.NonNull; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class AwsKmsTestKmsFacade extends AbstractAwsKmsTestKmsFacade { + private static final Logger LOG = LoggerFactory.getLogger(AwsKmsTestKmsFacade.class); + private static final DockerImageName LOCALSTACK_IMAGE = DockerImageName.parse("localstack/localstack:3.4"); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final TypeReference CREATE_KEY_RESPONSE_TYPE_REF = new TypeReference<>() { + }; + private static final TypeReference DESCRIBE_KEY_RESPONSE_TYPE_REF = new TypeReference<>() { + }; + private static final TypeReference SCHEDULE_KEY_DELETION_RESPONSE_TYPE_REF = new TypeReference<>() { + }; + private static final TypeReference ERROR_RESPONSE_TYPE_REF = new TypeReference<>() { + }; + private static final String TRENT_SERVICE_DESCRIBE_KEY = "TrentService.DescribeKey"; + private static final String TRENT_SERVICE_CREATE_KEY = "TrentService.CreateKey"; + private static final String TRENT_SERVICE_CREATE_ALIAS = "TrentService.CreateAlias"; + private static final String TRENT_SERVICE_UPDATE_ALIAS = "TrentService.UpdateAlias"; + private static final String TRENT_SERVICE_DELETE_ALIAS = "TrentService.DeleteAlias"; + private static final String TRENT_SERVICE_SCHEDULE_KEY_DELETION = "TrentService.ScheduleKeyDeletion"; + private final HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build(); + private LocalStackContainer localStackContainer; + + @Override + public boolean isAvailable() { + return DockerClientFactory.instance().isDockerAvailable(); + } + + @Override + @SuppressWarnings("resource") + public void startKms() { + localStackContainer = new LocalStackContainer(LOCALSTACK_IMAGE) { + @Override + @SuppressWarnings("java:S1874") + public LocalStackContainer withFileSystemBind(String hostPath, String containerPath) { + if (containerPath.endsWith("docker.sock")) { + LOG.debug("Skipped filesystem bind for {} => {}", hostPath, containerPath); + // Testcontainers mounts the docker.sock into the Localstack container by default. + // This is relied upon by only the Lambda Provider. By default, Podman prevents + // containers accessing the docker.sock (unless run in rootful mode). Since the + // Lambda Provider is not required by our use-case, skipping the filesystem bind is + // the simplest option. + // https://docs.localstack.cloud/getting-started/installation/#docker + // https://github.com/containers/podman/issues/6015 + return this; + } + else { + return super.withFileSystemBind(hostPath, containerPath); + } + } + }.withServices(LocalStackContainer.Service.KMS); + + localStackContainer.start(); + } + + @Override + public void stopKms() { + if (localStackContainer != null) { + localStackContainer.close(); + } + } + + private static T decodeJson(TypeReference valueTypeRef, byte[] bytes) { + try { + return OBJECT_MAPPER.readValue(bytes, valueTypeRef); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + @NonNull + protected URI getAwsUrl() { + return localStackContainer.getEndpointOverride(LocalStackContainer.Service.KMS); + } + + @Override + protected String getRegion() { + return localStackContainer.getRegion(); + } + + @Override + protected String getSecretKey() { + return localStackContainer.getSecretKey(); + } + + @Override + protected String getAccessKey() { + return localStackContainer.getAccessKey(); + } + + @Override + public TestKekManager getTestKekManager() { + return new AwsKmsTestKekManager(); + } + + class AwsKmsTestKekManager implements TestKekManager { + @Override + public void generateKek(String alias) { + Objects.requireNonNull(alias); + + if (exists(alias)) { + throw new AlreadyExistsException(alias); + } + else { + create(alias); + } + } + + @Override + public void rotateKek(String alias) { + Objects.requireNonNull(alias); + + if (!exists(alias)) { + throw new UnknownAliasException(alias); + } + else { + rotate(alias); + } + } + + @Override + public void deleteKek(String alias) { + if (!exists(alias)) { + throw new UnknownAliasException(alias); + } + else { + delete(alias); + } + } + + @Override + public boolean exists(String alias) { + try { + read(alias); + return true; + } + catch (UnknownAliasException uae) { + return false; + } + } + + private void create(String alias) { + final CreateKeyRequest createKey = new CreateKeyRequest("key for alias : " + alias); + var createRequest = createRequest(createKey, TRENT_SERVICE_CREATE_KEY); + var createKeyResponse = sendRequest(alias, createRequest, CREATE_KEY_RESPONSE_TYPE_REF); + + final CreateAliasRequest createAlias = new CreateAliasRequest(createKeyResponse.keyMetadata().keyId(), AwsKms.ALIAS_PREFIX + alias); + var aliasRequest = createRequest(createAlias, TRENT_SERVICE_CREATE_ALIAS); + sendRequestExpectingNoResponse(aliasRequest); + } + + private DescribeKeyResponse read(String alias) { + final DescribeKeyRequest describeKey = new DescribeKeyRequest(AwsKms.ALIAS_PREFIX + alias); + var request = createRequest(describeKey, TRENT_SERVICE_DESCRIBE_KEY); + return sendRequest(alias, request, DESCRIBE_KEY_RESPONSE_TYPE_REF); + } + + private void rotate(String alias) { + // RotateKeyOnDemand is not implemented in localstack. + // https://docs.localstack.cloud/references/coverage/coverage_kms/#:~:text=Show%20Tests-,RotateKeyOnDemand,-ScheduleKeyDeletion + // https://github.com/localstack/localstack/issues/10723 + + // mimic a rotate by creating a new key and repoint the alias at it, leaving the original + // key in place. + final CreateKeyRequest request = new CreateKeyRequest("[rotated] key for alias : " + alias); + var keyRequest = createRequest(request, TRENT_SERVICE_CREATE_KEY); + var createKeyResponse = sendRequest(alias, keyRequest, CREATE_KEY_RESPONSE_TYPE_REF); + + final UpdateAliasRequest update = new UpdateAliasRequest(createKeyResponse.keyMetadata().keyId(), AwsKms.ALIAS_PREFIX + alias); + var aliasRequest = createRequest(update, TRENT_SERVICE_UPDATE_ALIAS); + sendRequestExpectingNoResponse(aliasRequest); + } + + private void delete(String alias) { + var key = read(alias); + var keyId = key.keyMetadata().keyId(); + final ScheduleKeyDeletionRequest request = new ScheduleKeyDeletionRequest(keyId, 7 /* Minimum allowed */); + var scheduleDeleteRequest = createRequest(request, TRENT_SERVICE_SCHEDULE_KEY_DELETION); + + sendRequest(keyId, scheduleDeleteRequest, SCHEDULE_KEY_DELETION_RESPONSE_TYPE_REF); + + final DeleteAliasRequest deleteAlias = new DeleteAliasRequest(AwsKms.ALIAS_PREFIX + alias); + var deleteAliasRequest = createRequest(deleteAlias, TRENT_SERVICE_DELETE_ALIAS); + sendRequestExpectingNoResponse(deleteAliasRequest); + } + + private HttpRequest createRequest(Object request, String target) { + var body = getBody(request).getBytes(UTF_8); + + return AwsV4SigningHttpRequestBuilder.newBuilder(getAccessKey(), getSecretKey(), getRegion(), "kms", Instant.now()) + .uri(getAwsUrl()) + .header(AwsKms.CONTENT_TYPE_HEADER, AwsKms.APPLICATION_X_AMZ_JSON_1_1) + .header(AwsKms.X_AMZ_TARGET_HEADER, target) + .POST(BodyPublishers.ofByteArray(body)) + .build(); + } + + private R sendRequest(String key, HttpRequest request, TypeReference valueTypeRef) { + try { + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofByteArray()); + checkForError(key, request.uri(), response.statusCode(), response); + return decodeJson(valueTypeRef, response.body()); + } + catch (IOException e) { + if (e.getCause() instanceof KmsException ke) { + throw ke; + } + throw new UncheckedIOException("Request to %s failed".formatted(request), e); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted during REST API call : %s".formatted(request.uri()), e); + } + } + + private void checkForError(String key, URI uri, int statusCode, HttpResponse response) { + ErrorResponse error; + // AWS API states that only the 200 response is currently used. + // Our HTTP client is configured to follow redirects so 3xx responses are not expected here. + var httpSuccess = isHttpSuccess(statusCode); + if (!httpSuccess) { + try { + error = decodeJson(ERROR_RESPONSE_TYPE_REF, response.body()); + } + catch (UncheckedIOException e) { + error = null; + } + if (error != null && error.isNotFound()) { + throw new UnknownAliasException(key); + } + throw new IllegalStateException("unexpected response %s (AWS error: %s) for request: %s".formatted(response.statusCode(), error, uri)); + } + } + + private void sendRequestExpectingNoResponse(HttpRequest request) { + try { + var response = client.send(request, HttpResponse.BodyHandlers.discarding()); + if (!isHttpSuccess(response.statusCode())) { + throw new IllegalStateException("Unexpected response : %d to request %s".formatted(response.statusCode(), request.uri())); + } + } + catch (IOException e) { + throw new UncheckedIOException("Request to %s failed".formatted(request), e); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private boolean isHttpSuccess(int statusCode) { + return statusCode >= 200 && statusCode < 300; + } + + private String getBody(Object obj) { + try { + return OBJECT_MAPPER.writeValueAsString(obj); + } + catch (JsonProcessingException e) { + throw new UncheckedIOException("Failed to create request body", e); + } + } + } + +} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTestKmsFacadeFactory.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTestKmsFacadeFactory.java new file mode 100644 index 0000000000..e683bc64fe --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTestKmsFacadeFactory.java @@ -0,0 +1,23 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import io.kroxylicious.kms.provider.aws.kms.config.Config; +import io.kroxylicious.kms.service.TestKmsFacadeFactory; + +/** + * Factory for {@link AwsKmsTestKmsFacade}s. + */ +public class AwsKmsTestKmsFacadeFactory extends AbstractAwsKmsTestKmsFacadeFactory implements TestKmsFacadeFactory { + /** + * {@inheritDoc} + */ + @Override + public AwsKmsTestKmsFacade build() { + return new AwsKmsTestKmsFacade(); + } +} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/CreateAliasRequest.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/CreateAliasRequest.java new file mode 100644 index 0000000000..12446d4773 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/CreateAliasRequest.java @@ -0,0 +1,21 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +public record CreateAliasRequest(@JsonProperty("TargetKeyId") @NonNull String targetKeyId, + @JsonProperty("AliasName") @NonNull String aliasName) { + public CreateAliasRequest { + Objects.requireNonNull(targetKeyId); + Objects.requireNonNull(aliasName); + } +} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/CreateKeyRequest.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/CreateKeyRequest.java new file mode 100644 index 0000000000..aa587ac95d --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/CreateKeyRequest.java @@ -0,0 +1,11 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record CreateKeyRequest(@JsonProperty("description") String description) {} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/CreateKeyResponse.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/CreateKeyResponse.java new file mode 100644 index 0000000000..179610646f --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/CreateKeyResponse.java @@ -0,0 +1,21 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record CreateKeyResponse(@JsonProperty("KeyMetadata") @NonNull KeyMetadata keyMetadata) { + public CreateKeyResponse { + Objects.requireNonNull(keyMetadata); + } +} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DeleteAliasRequest.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DeleteAliasRequest.java new file mode 100644 index 0000000000..b8f5831001 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DeleteAliasRequest.java @@ -0,0 +1,19 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +public record DeleteAliasRequest(@JsonProperty("AliasName") @NonNull String aliasName) { + public DeleteAliasRequest { + Objects.requireNonNull(aliasName); + } +} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/ScheduleKeyDeletionRequest.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/ScheduleKeyDeletionRequest.java new file mode 100644 index 0000000000..a1a686ee9e --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/ScheduleKeyDeletionRequest.java @@ -0,0 +1,21 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +public record ScheduleKeyDeletionRequest(@JsonProperty(value = "KeyId") @NonNull String keyId, + @JsonProperty("PendingWindowInDays") int pendingWindowInDays) { + + public ScheduleKeyDeletionRequest { + Objects.requireNonNull(keyId); + } +} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/ScheduleKeyDeletionResponse.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/ScheduleKeyDeletionResponse.java new file mode 100644 index 0000000000..26735da5b0 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/ScheduleKeyDeletionResponse.java @@ -0,0 +1,23 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ScheduleKeyDeletionResponse(@JsonProperty(value = "KeyState") @NonNull String keyState, + @JsonProperty(value = "PendingWindowInDays") int pendingWindowInDays) { + + public ScheduleKeyDeletionResponse { + Objects.requireNonNull(keyState); + } +} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/UpdateAliasRequest.java b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/UpdateAliasRequest.java new file mode 100644 index 0000000000..943e2dfc37 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/UpdateAliasRequest.java @@ -0,0 +1,21 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +public record UpdateAliasRequest(@JsonProperty("TargetKeyId") @NonNull String targetKeyId, + @JsonProperty("AliasName") @NonNull String aliasName) { + public UpdateAliasRequest { + Objects.requireNonNull(targetKeyId); + Objects.requireNonNull(aliasName); + } +} diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/main/resources/META-INF/services/io.kroxylicious.kms.service.TestKmsFacadeFactory b/kroxylicious-kms-provider-aws-kms-test-support/src/main/resources/META-INF/services/io.kroxylicious.kms.service.TestKmsFacadeFactory new file mode 100644 index 0000000000..ca2a8444a6 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/main/resources/META-INF/services/io.kroxylicious.kms.service.TestKmsFacadeFactory @@ -0,0 +1,7 @@ +# +# Copyright Kroxylicious Authors. +# +# Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 +# + +io.kroxylicious.kms.provider.aws.kms.AwsKmsTestKmsFacadeFactory \ No newline at end of file diff --git a/kroxylicious-kms-provider-aws-kms-test-support/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTestKmsFacadeTest.java b/kroxylicious-kms-provider-aws-kms-test-support/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTestKmsFacadeTest.java new file mode 100644 index 0000000000..a88834e803 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms-test-support/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTestKmsFacadeTest.java @@ -0,0 +1,38 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.DockerClientFactory; + +import io.kroxylicious.kms.provider.aws.kms.config.Config; +import io.kroxylicious.kms.service.AbstractTestKmsFacadeTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assumptions.assumeThat; + +class AwsKmsTestKmsFacadeTest extends AbstractTestKmsFacadeTest { + + AwsKmsTestKmsFacadeTest() { + super(new AwsKmsTestKmsFacadeFactory()); + } + + @BeforeEach + void beforeEach() { + assumeThat(DockerClientFactory.instance().isDockerAvailable()).withFailMessage("docker unavailable").isTrue(); + } + + @Test + void classAndConfig() { + try (var facade = factory.build()) { + facade.start(); + assertThat(facade.getKmsServiceClass()).isEqualTo(AwsKmsService.class); + assertThat(facade.getKmsServiceConfig()).isInstanceOf(Config.class); + } + } +} diff --git a/kroxylicious-kms-provider-aws-kms/pom.xml b/kroxylicious-kms-provider-aws-kms/pom.xml new file mode 100644 index 0000000000..73e6a58fe9 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/pom.xml @@ -0,0 +1,100 @@ + + + + + 4.0.0 + + io.kroxylicious + kroxylicious-parent + 0.6.0-SNAPSHOT + ../pom.xml + + + kroxylicious-kms-provider-aws-kms + + AWS KMS + A KMS provider backed by a remote instance of AWS KMS + + + + io.kroxylicious + kroxylicious-kms + + + io.kroxylicious + kroxylicious-annotations + + + io.kroxylicious + kroxylicious-api + + + + + com.fasterxml.jackson.core + jackson-annotations + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + + org.apache.kafka + kafka-clients + + + org.slf4j + slf4j-api + + + com.github.spotbugs + spotbugs-annotations + provided + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.assertj + assertj-core + test + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + test + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + test + + + io.kroxylicious + kroxylicious-kms-test-support + test + + + \ No newline at end of file diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKms.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKms.java new file mode 100644 index 0000000000..17affb515b --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKms.java @@ -0,0 +1,232 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +import javax.crypto.SecretKey; +import javax.net.ssl.SSLContext; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.kroxylicious.kms.provider.aws.kms.model.DecryptRequest; +import io.kroxylicious.kms.provider.aws.kms.model.DecryptResponse; +import io.kroxylicious.kms.provider.aws.kms.model.DescribeKeyRequest; +import io.kroxylicious.kms.provider.aws.kms.model.DescribeKeyResponse; +import io.kroxylicious.kms.provider.aws.kms.model.ErrorResponse; +import io.kroxylicious.kms.provider.aws.kms.model.GenerateDataKeyRequest; +import io.kroxylicious.kms.provider.aws.kms.model.GenerateDataKeyResponse; +import io.kroxylicious.kms.provider.aws.kms.model.KeyMetadata; +import io.kroxylicious.kms.service.DekPair; +import io.kroxylicious.kms.service.DestroyableRawSecretKey; +import io.kroxylicious.kms.service.Kms; +import io.kroxylicious.kms.service.KmsException; +import io.kroxylicious.kms.service.Serde; +import io.kroxylicious.kms.service.UnknownAliasException; +import io.kroxylicious.kms.service.UnknownKeyException; + +import edu.umd.cs.findbugs.annotations.NonNull; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * An implementation of the KMS interface backed by a remote instance of AWS Key Management Service. + *
+ * The approach taken by this implementation is to make direct calls to the AWS KMS API over REST (rather than relying upon the AWS KMS SDK). This + * is done in order to avoid the (significant) dependencies of the AWS SDK to the class-path. + */ +public class AwsKms implements Kms { + + static final String APPLICATION_X_AMZ_JSON_1_1 = "application/x-amz-json-1.1"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String AES_KEY_ALGO = "AES"; + private static final TypeReference DESCRIBE_KEY_RESPONSE_TYPE_REF = new TypeReference<>() { + }; + private static final TypeReference GENERATE_DATA_KEY_RESPONSE_TYPE_REF = new TypeReference<>() { + }; + private static final TypeReference DECRYPT_RESPONSE_TYPE_REF = new TypeReference<>() { + }; + private static final TypeReference ERROR_RESPONSE_TYPE_REF = new TypeReference<>() { + }; + private static final String TRENT_SERVICE_DESCRIBE_KEY = "TrentService.DescribeKey"; + private static final String TRENT_SERVICE_GENERATE_DATA_KEY = "TrentService.GenerateDataKey"; + private static final String TRENT_SERVICE_DECRYPT = "TrentService.Decrypt"; + static final String CONTENT_TYPE_HEADER = "Content-Type"; + static final String X_AMZ_TARGET_HEADER = "X-Amz-Target"; + static final String ALIAS_PREFIX = "alias/"; + + private final String accessKey; + private final String secretKey; + private final String region; + private final Duration timeout; + private final HttpClient client; + + /** + * The AWS KMS url. + */ + private final URI awsUrl; + + AwsKms(URI awsUrl, String accessKey, String secretKey, String region, Duration timeout, SSLContext sslContext) { + Objects.requireNonNull(awsUrl); + Objects.requireNonNull(accessKey); + Objects.requireNonNull(secretKey); + Objects.requireNonNull(region); + this.awsUrl = awsUrl; + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.timeout = timeout; + client = createClient(sslContext); + } + + private HttpClient createClient(SSLContext sslContext) { + HttpClient.Builder builder = HttpClient.newBuilder(); + if (sslContext != null) { + builder.sslContext(sslContext); + } + return builder + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(timeout) + .build(); + } + + /** + * {@inheritDoc} + *
+ * @see https://docs.aws.amazon.com/kms/latest/APIReference/API_GenerateDataKey.html + */ + @NonNull + @Override + public CompletionStage> generateDekPair(@NonNull String kekRef) { + final GenerateDataKeyRequest generateRequest = new GenerateDataKeyRequest(kekRef, "AES_256"); + var request = createRequest(generateRequest, TRENT_SERVICE_GENERATE_DATA_KEY); + return sendAsync(kekRef, request, GENERATE_DATA_KEY_RESPONSE_TYPE_REF, UnknownKeyException::new) + .thenApply(response -> { + var key = DestroyableRawSecretKey.takeOwnershipOf(response.plaintext(), AES_KEY_ALGO); + return new DekPair<>(new AwsKmsEdek(kekRef, response.ciphertextBlob()), key); + }); + } + + /** + * {@inheritDoc} + *
+ * @see https://docs.aws.amazon.com/kms/latest/APIReference/API_Decrypt.html + */ + @NonNull + @Override + public CompletionStage decryptEdek(@NonNull AwsKmsEdek edek) { + final DecryptRequest decryptRequest = new DecryptRequest(edek.kekRef(), edek.edek()); + var request = createRequest(decryptRequest, TRENT_SERVICE_DECRYPT); + return sendAsync(edek.kekRef(), request, DECRYPT_RESPONSE_TYPE_REF, UnknownKeyException::new) + .thenApply(response -> DestroyableRawSecretKey.takeOwnershipOf(response.plaintext(), AES_KEY_ALGO)); + } + + /** + * {@inheritDoc} + *
+ * @see https://docs.aws.amazon.com/kms/latest/APIReference/API_DescribeKey.html + */ + @NonNull + @Override + public CompletableFuture resolveAlias(@NonNull String alias) { + final DescribeKeyRequest resolveRequest = new DescribeKeyRequest(ALIAS_PREFIX + alias); + var request = createRequest(resolveRequest, TRENT_SERVICE_DESCRIBE_KEY); + return sendAsync(alias, request, DESCRIBE_KEY_RESPONSE_TYPE_REF, UnknownAliasException::new) + .thenApply(DescribeKeyResponse::keyMetadata) + .thenApply(KeyMetadata::keyId); + } + + private CompletableFuture sendAsync(@NonNull String key, HttpRequest request, + TypeReference valueTypeRef, + Function exception) { + return client.sendAsync(request, HttpResponse.BodyHandlers.ofByteArray()) + .thenApply(response -> checkResponseStatus(key, response, exception)) + .thenApply(HttpResponse::body) + .thenApply(bytes -> decodeJson(valueTypeRef, bytes)); + } + + private static T decodeJson(TypeReference valueTypeRef, byte[] bytes) { + try { + return OBJECT_MAPPER.readValue(bytes, valueTypeRef); + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @NonNull + private static HttpResponse checkResponseStatus(@NonNull String key, + @NonNull HttpResponse response, + @NonNull Function notFound) { + var statusCode = response.statusCode(); + // AWS API states that only the 200 response is currently used. + // Our HTTP client is configured to follow redirects so 3xx responses are not expected here. + var httpSuccess = statusCode >= 200 && statusCode < 300; + if (!httpSuccess) { + ErrorResponse error; + try { + error = decodeJson(ERROR_RESPONSE_TYPE_REF, response.body()); + } + catch (UncheckedIOException e) { + error = null; + } + + if (error != null && error.isNotFound()) { + throw notFound.apply("key '%s' is not found (AWS error: %s).".formatted(key, error)); + } + + throw new KmsException("Operation failed, key %s, HTTP status code %d, AWS error: %s".formatted(key, statusCode, error)); + } + return response; + } + + @NonNull + @Override + public Serde edekSerde() { + return AwsKmsEdekSerde.instance(); + } + + @NonNull + private URI getAwsUrl() { + return awsUrl; + } + + private HttpRequest createRequest(Object request, String target) { + + var body = getBody(request).getBytes(UTF_8); + + return AwsV4SigningHttpRequestBuilder.newBuilder(accessKey, secretKey, region, "kms", Instant.now()) + .uri(getAwsUrl()) + .header(CONTENT_TYPE_HEADER, APPLICATION_X_AMZ_JSON_1_1) + .header(X_AMZ_TARGET_HEADER, target) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)) + .build(); + } + + private String getBody(Object obj) { + try { + return OBJECT_MAPPER.writeValueAsString(obj); + } + catch (JsonProcessingException e) { + throw new UncheckedIOException("Failed to create request body", e); + } + } + +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdek.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdek.java new file mode 100644 index 0000000000..6a9b11e00b --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdek.java @@ -0,0 +1,70 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.util.Arrays; +import java.util.Objects; + +/** + * An AWS KMS Encrypted Dek. + * + * @param kekRef - kek reference. + * @param edek - edek bytes + */ +record AwsKmsEdek(String kekRef, + byte[] edek) { + AwsKmsEdek { + Objects.requireNonNull(kekRef); + Objects.requireNonNull(edek); + if (kekRef.isEmpty()) { + throw new IllegalArgumentException("keyRef cannot be empty"); + } + if (edek.length == 0) { + throw new IllegalArgumentException("edek cannot be empty"); + } + } + + /** + * Overridden to provide deep equality on the {@code byte[]}. + * @param o the reference object with which to compare. + * @return true iff this object is equal to the given object. + */ + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AwsKmsEdek that = (AwsKmsEdek) o; + return Objects.equals(kekRef, that.kekRef) && Arrays.equals(edek, that.edek); + } + + /** + * Overridden to provide a deep hashcode on the {@code byte[]}. + * @return the has code. + */ + @Override + public int hashCode() { + int result = Objects.hashCode(kekRef); + result = 31 * result + Arrays.hashCode(edek); + return result; + } + + /** + * Overridden to provide a deep {@code toString()} on the {@code byte[]}. + * @return The string + */ + @Override + public String toString() { + return "AwsKmsEdek{" + + "keyRef=" + kekRef + + ", edek=" + Arrays.toString(edek) + + '}'; + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdekSerde.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdekSerde.java new file mode 100644 index 0000000000..904e7f8790 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdekSerde.java @@ -0,0 +1,88 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +import io.kroxylicious.kms.service.Serde; + +import edu.umd.cs.findbugs.annotations.NonNull; + +import static java.lang.Math.toIntExact; +import static org.apache.kafka.common.utils.ByteUtils.readUnsignedVarint; +import static org.apache.kafka.common.utils.ByteUtils.sizeOfUnsignedVarint; +import static org.apache.kafka.common.utils.ByteUtils.writeUnsignedVarint; +import static org.apache.kafka.common.utils.Utils.utf8; +import static org.apache.kafka.common.utils.Utils.utf8Length; + +/** + * Serde for the AwsKmsEdek. + *
+ * The serialization structure is as follows: + *
    + *
  1. version byte (currently always zero)
  2. + *
  3. Protobuf Unsigned varint to hold the number of bytes required to hold the UTF-8 representation of the kekRef.
  4. + *
  5. UTF-8 representation of the kefRef.
  6. + *
  7. Bytes of the edek.
  8. + *
+ * @see Protobuf Encodings + */ +class AwsKmsEdekSerde implements Serde { + + private static final AwsKmsEdekSerde INSTANCE = new AwsKmsEdekSerde(); + + public static final byte VERSION_0 = (byte) 0; + + static Serde instance() { + return INSTANCE; + } + + private AwsKmsEdekSerde() { + } + + @Override + public AwsKmsEdek deserialize(@NonNull ByteBuffer buffer) { + Objects.requireNonNull(buffer); + + var version = buffer.get(); + if (version != VERSION_0) { + throw new IllegalArgumentException("Unexpected version byte, got: %d expecting: %d".formatted(version, VERSION_0)); + } + var kekRefLength = toIntExact(readUnsignedVarint(buffer)); + var kekRef = utf8(buffer, kekRefLength); + buffer.position(buffer.position() + kekRefLength); + + int edekLength = buffer.remaining(); + var edek = new byte[edekLength]; + buffer.get(edek); + + return new AwsKmsEdek(kekRef, edek); + } + + @Override + public int sizeOf(AwsKmsEdek edek) { + Objects.requireNonNull(edek); + int kekRefLen = utf8Length(edek.kekRef()); + return 1 // version byte + + sizeOfUnsignedVarint(kekRefLen) // varint to store length of kek + + kekRefLen // n bytes for the utf-8 encoded kek + + edek.edek().length; // n for the bytes of the edek + } + + @Override + public void serialize(AwsKmsEdek edek, @NonNull ByteBuffer buffer) { + Objects.requireNonNull(edek); + Objects.requireNonNull(buffer); + var keyRefBuf = edek.kekRef().getBytes(StandardCharsets.UTF_8); + buffer.put(VERSION_0); + writeUnsignedVarint(keyRefBuf.length, buffer); + buffer.put(keyRefBuf); + buffer.put(edek.edek()); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsService.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsService.java new file mode 100644 index 0000000000..87572acf3c --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsService.java @@ -0,0 +1,33 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.time.Duration; + +import io.kroxylicious.kms.provider.aws.kms.config.Config; +import io.kroxylicious.kms.service.KmsService; +import io.kroxylicious.proxy.plugin.Plugin; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * An implementation of the {@link KmsService} interface backed by a remote instance of AWS KMS. + */ +@Plugin(configType = Config.class) +public class AwsKmsService implements KmsService { + + @NonNull + @Override + public AwsKms buildKms(Config options) { + return new AwsKms(options.endpointUrl(), + options.accessKey().getProvidedPassword(), + options.secretKey().getProvidedPassword(), + options.region(), + Duration.ofSeconds(20), options.sslContext()); + } + +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsV4SigningHttpRequestBuilder.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsV4SigningHttpRequestBuilder.java new file mode 100644 index 0000000000..86428bee5c --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/AwsV4SigningHttpRequestBuilder.java @@ -0,0 +1,381 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.Flow; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import io.kroxylicious.kms.service.KmsException; +import io.kroxylicious.proxy.tag.VisibleForTesting; + +import edu.umd.cs.findbugs.annotations.NonNull; + +import static java.net.http.HttpRequest.BodyPublisher; +import static java.net.http.HttpRequest.Builder; + +/** + * An implementation of HttpRequestBuilder that signs AWS requests + * accordance with AWS v4. + */ +class AwsV4SigningHttpRequestBuilder implements Builder { + + private static final Pattern CONSECUTIVE_WHITESPACE = Pattern.compile("\\s+"); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); + + private static final HexFormat HEX_FORMATTER = HexFormat.of(); + + private static final String NO_PAYLOAD_HEXED_SHA256 = HEX_FORMATTER.formatHex(newSha256Digester().digest(new byte[]{})); + + private static final String X_AMZ_DATE_HEADER = "X-Amz-Date"; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String HOST_HEADER = "Host"; + private static final String AWS_4_REQUEST = "aws4_request"; + + private final String accessKey; + private final String secretKey; + private final String region; + private final String service; + private final Instant date; + private final Builder builder; + private String payloadHexedSha56; + + /** + * + * Creates an AwsV4SigningHttpRequestBuilder builder. + * + * @param accessKey AWS access key + * @param secretKey AWS secret key + * @param region AWS region + * @param service AWS service + * @param date request date + * @return a new request builder + */ + public static Builder newBuilder(@NonNull String accessKey, @NonNull String secretKey, @NonNull String region, @NonNull String service, @NonNull Instant date) { + return new AwsV4SigningHttpRequestBuilder(accessKey, secretKey, region, service, date, HttpRequest.newBuilder()); + } + + private AwsV4SigningHttpRequestBuilder(String accessKey, String secretKey, String region, String service, Instant date, Builder builder) { + Objects.requireNonNull(accessKey); + Objects.requireNonNull(secretKey); + Objects.requireNonNull(region); + Objects.requireNonNull(service); + Objects.requireNonNull(date); + Objects.requireNonNull(builder); + this.accessKey = accessKey; + this.secretKey = secretKey; + this.region = region; + this.service = service; + this.date = date; + this.builder = builder; + } + + @Override + public Builder expectContinue(boolean enable) { + builder.expectContinue(enable); + return this; + } + + @Override + public Builder version(HttpClient.Version version) { + builder.version(version); + return this; + } + + @Override + public Builder header(String name, String value) { + builder.header(name, value); + return this; + } + + @Override + public Builder headers(String... headers) { + builder.headers(headers); + return this; + } + + @Override + public Builder timeout(Duration duration) { + builder.timeout(duration); + return this; + } + + @Override + public Builder setHeader(String name, String value) { + builder.setHeader(name, value); + return this; + } + + @Override + public Builder GET() { + builder.GET(); + return this; + } + + @Override + public Builder POST(BodyPublisher bodyPublisher) { + builder.POST(digestingPublisher(bodyPublisher)); + return this; + } + + @Override + public Builder PUT(BodyPublisher bodyPublisher) { + builder.PUT(digestingPublisher(bodyPublisher)); + return this; + } + + @Override + public Builder DELETE() { + builder.DELETE(); + return this; + } + + @Override + public Builder method(String method, BodyPublisher bodyPublisher) { + builder.method(method, digestingPublisher(bodyPublisher)); + return this; + } + + @Override + public Builder copy() { + return new AwsV4SigningHttpRequestBuilder(accessKey, secretKey, region, service, date, builder.copy()); + } + + @Override + public Builder uri(URI uri) { + builder.uri(uri); + return this; + } + + @Override + public HttpRequest build() { + signRequest(); + return builder.build(); + } + + @NonNull + private BodyPublisher digestingPublisher(BodyPublisher bodyPublisher) { + var items = new ArrayList(); + + bodyPublisher.subscribe(new Flow.Subscriber<>() { + final MessageDigest digest = newSha256Digester(); + + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(ByteBuffer item) { + digest.update(item.array()); + items.add(HttpRequest.BodyPublishers.ofByteArray(item.array())); + } + + @Override + public void onError(Throwable throwable) { + throw new IllegalStateException(throwable); + } + + @Override + public void onComplete() { + payloadHexedSha56 = HEX_FORMATTER.formatHex(digest.digest()); + } + }); + + return HttpRequest.BodyPublishers.concat(items.toArray(new BodyPublisher[]{})); + } + + /** + * This method implements signs the request + * using the AWS v4 algorithm. + */ + private void signRequest() { + var isoDateTime = DATE_TIME_FORMATTER.format(date); + var isoDate = isoDateTime.substring(0, 8); + var unsignedRequest = builder.build(); + + // Note: AWS only specify signing behaviour for headers with a single value. + var allHeaders = new HashMap<>(getSingleValuedHeaders(unsignedRequest)); + allHeaders.put(HOST_HEADER, getHostHeaderForSigning(unsignedRequest.uri())); + allHeaders.put(X_AMZ_DATE_HEADER, isoDateTime); + + var canonicalRequest = computeCanonicalRequest(allHeaders, unsignedRequest); + var stringToSign = computeStringToSign(canonicalRequest, isoDateTime, isoDate); + var authorization = computeAuthorization(canonicalRequest, stringToSign, isoDate); + + builder.header(AUTHORIZATION_HEADER, authorization); + builder.header(X_AMZ_DATE_HEADER, isoDateTime); + } + + @NonNull + private CanonicalRequestResult computeCanonicalRequest(Map allHeaders, HttpRequest unsignedRequest) { + var method = unsignedRequest.method(); + var uri = unsignedRequest.uri(); + var path = Optional.ofNullable(uri.getPath()).filter(Predicate.not(String::isEmpty)).orElse("/"); + var query = Optional.ofNullable(uri.getQuery()).orElse(""); + + var canonicalRequestLines = new ArrayList(); + canonicalRequestLines.add(method); + canonicalRequestLines.add(path); + canonicalRequestLines.add(query); + var hashedHeaders = new ArrayList(allHeaders.size()); + var headerKeysSorted = allHeaders.keySet().stream().sorted(Comparator.comparing(n -> n.toLowerCase(Locale.ROOT))).toList(); + for (String key : headerKeysSorted) { + hashedHeaders.add(key.toLowerCase(Locale.ROOT)); + canonicalRequestLines.add(key.toLowerCase(Locale.ROOT) + ":" + normalizeHeaderValue((allHeaders).get(key))); + } + canonicalRequestLines.add(null); + var signedHeaders = String.join(";", hashedHeaders); + canonicalRequestLines.add(signedHeaders); + canonicalRequestLines.add(payloadHexedSha56 == null ? NO_PAYLOAD_HEXED_SHA256 : payloadHexedSha56); + var canonicalRequestBody = canonicalRequestLines.stream().map(line -> line == null ? "" : line).collect(Collectors.joining("\n")); + var canonicalRequestHash = HEX_FORMATTER.formatHex(sha256(canonicalRequestBody.getBytes(StandardCharsets.UTF_8))); + return new CanonicalRequestResult(signedHeaders, canonicalRequestHash); + } + + private record CanonicalRequestResult(String signedHeaders, String canonicalRequestHash) {} + + @NonNull + private StringToSignResult computeStringToSign(CanonicalRequestResult canonicalRequestResult, String isoDateTime, String isoDate) { + var stringToSignLines = new ArrayList(); + stringToSignLines.add("AWS4-HMAC-SHA256"); + stringToSignLines.add(isoDateTime); + var credentialScope = isoDate + "/" + region + "/" + service + "/" + AWS_4_REQUEST; + stringToSignLines.add(credentialScope); + stringToSignLines.add(canonicalRequestResult.canonicalRequestHash()); + var stringToSign = String.join("\n", stringToSignLines); + return new StringToSignResult(credentialScope, stringToSign); + } + + private record StringToSignResult(String credentialScope, String stringToSign) {} + + @NonNull + private String computeAuthorization(CanonicalRequestResult canonicalRequestResult, StringToSignResult stringToSignResult, String isoDate) { + var dateHmac = hmac(("AWS4" + secretKey).getBytes(StandardCharsets.UTF_8), isoDate); + var regionHmac = hmac(dateHmac, this.region); + var serviceHmac = hmac(regionHmac, this.service); + var signHmac = hmac(serviceHmac, AWS_4_REQUEST); + var signature = HEX_FORMATTER.formatHex(hmac(signHmac, stringToSignResult.stringToSign())); + + return "AWS4-HMAC-SHA256 Credential=" + accessKey + "/" + stringToSignResult.credentialScope() + ", SignedHeaders=" + canonicalRequestResult.signedHeaders() + + ", Signature=" + signature; + } + + /** + * This implementation must match the HTTP client's computation of the contents of the Host header. + * + * @param uri uri + * @return host string + */ + @VisibleForTesting + String getHostHeaderForSigning(URI uri) { + int port = uri.getPort(); + String host = uri.getHost(); + + boolean defaultPort; + if (port == -1) { + defaultPort = true; + } + else if (uri.getScheme().toLowerCase(Locale.ROOT).equals("https")) { + defaultPort = port == 443; + } + else { + defaultPort = port == 80; + } + + if (defaultPort) { + return host; + } + else { + return host + ":" + port; + } + } + + /** + * Canonical header values must remove excess white space before and after values, and convert sequential spaces to a single + * space. See canonicalizedHeaderString_valuesWithExtraWhitespace_areTrimmed + * + * @param value header value + * @return normalised header value + */ + private static String normalizeHeaderValue(String value) { + return CONSECUTIVE_WHITESPACE.matcher(value).replaceAll(" ").trim(); + } + + private static byte[] sha256(byte[] bytes) { + var digest = newSha256Digester(); + digest.update(bytes); + return digest.digest(); + } + + private static byte[] hmac(byte[] key, String msg) { + try { + var mac = newHmacSha256(); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(msg.getBytes(StandardCharsets.UTF_8)); + } + catch (InvalidKeyException e) { + throw new KmsException("Failed to initialize hmac", e); + } + } + + @NonNull + private static Mac newHmacSha256() { + try { + return Mac.getInstance("HmacSHA256"); + } + catch (NoSuchAlgorithmException e) { + throw new KmsException("Failed to create SHA-256 hmac", e); + } + } + + @NonNull + private static MessageDigest newSha256Digester() { + try { + return MessageDigest.getInstance("SHA-256"); + } + catch (NoSuchAlgorithmException e) { + throw new KmsException("Failed to create SHA-256 digester", e); + } + } + + @NonNull + private static Map getSingleValuedHeaders(HttpRequest request) { + return request.headers().map().entrySet().stream() + .filter(AwsV4SigningHttpRequestBuilder::hasSingleValue) + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().get(0))); + } + + private static boolean hasSingleValue(Map.Entry> e) { + return e.getValue() != null && e.getValue().size() == 1; + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/config/Config.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/config/Config.java new file mode 100644 index 0000000000..090f4cb1b7 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/config/Config.java @@ -0,0 +1,58 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.config; + +import java.net.URI; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +import javax.net.ssl.SSLContext; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.kroxylicious.proxy.config.secret.PasswordProvider; +import io.kroxylicious.proxy.config.tls.Tls; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Configuration for the Vault KMS service. + * + * @param endpointUrl URL of the Vault Transit Engine e.g. {@code https://myhashicorpvault:8200/v1/transit} + * @param accessKey AWS accessKey + * @param secretKey the password provider that will provide the Vault token. + * @param region AWS region + */ + +public record Config( + @JsonProperty(value = "endpointUrl", required = true) URI endpointUrl, + @JsonProperty(required = true) PasswordProvider accessKey, + @JsonProperty(required = true) PasswordProvider secretKey, + @JsonProperty(required = true) String region, + Tls tls) { + public Config { + Objects.requireNonNull(endpointUrl); + Objects.requireNonNull(region); + Objects.requireNonNull(accessKey); + Objects.requireNonNull(secretKey); + } + + @NonNull + public SSLContext sslContext() { + try { + if (tls == null) { + return SSLContext.getDefault(); + } + else { + return new JdkTls(tls).sslContext(); + } + } + catch (NoSuchAlgorithmException e) { + throw new SslConfigurationException(e); + } + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/config/JdkTls.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/config/JdkTls.java new file mode 100644 index 0000000000..bc6563c9f0 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/config/JdkTls.java @@ -0,0 +1,197 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.config; + +import java.io.FileInputStream; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.Optional; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.kroxylicious.proxy.config.secret.PasswordProvider; +import io.kroxylicious.proxy.config.tls.InsecureTls; +import io.kroxylicious.proxy.config.tls.KeyPair; +import io.kroxylicious.proxy.config.tls.KeyProvider; +import io.kroxylicious.proxy.config.tls.KeyProviderVisitor; +import io.kroxylicious.proxy.config.tls.Tls; +import io.kroxylicious.proxy.config.tls.TrustProvider; +import io.kroxylicious.proxy.config.tls.TrustProviderVisitor; +import io.kroxylicious.proxy.config.tls.TrustStore; +import io.kroxylicious.proxy.tag.VisibleForTesting; + +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.Nullable; + +/** + * Encapsulates parameters for an TLS connection with AWS. + * + * @param tls tls configuration + * + * TODO ability to restrict by TLS protocol and cipher suite (#1006) + */ +public record JdkTls(Tls tls) { + + private static final Logger logger = LoggerFactory.getLogger(JdkTls.class); + + public JdkTls { + if (tls != null && tls.key() != null) { + logger.warn("TLS key material is currently not supported by the vault client"); + } + } + + public static final X509TrustManager INSECURE_TRUST_MANAGER = new X509TrustManager() { + + // suppressing sonar security warning as we are intentionally throwing security out the window + @SuppressWarnings("java:S4830") + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // do nothing, the api is to throw if not trusted + } + + // suppressing sonar security warning as we are intentionally throwing security out the window + @SuppressWarnings("java:S4830") + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // do nothing, the api is to throw if not trusted + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + }; + public static final TrustManager[] INSECURE_TRUST_MANAGERS = { INSECURE_TRUST_MANAGER }; + + public SSLContext sslContext() { + try { + if (tls != null) { + TrustManager[] trustManagers = null; + KeyManager[] keyManagers = null; + if (tls.trust() != null) { + trustManagers = getTrustManagers(tls.trust()); + } + if (tls.key() != null) { + keyManagers = getKeyManagers(tls.key()); + } + return getSslContext(trustManagers, keyManagers); + } + else { + return SSLContext.getDefault(); + } + } + catch (Exception e) { + throw new SslConfigurationException(e); + } + } + + @VisibleForTesting + static KeyManager[] getKeyManagers(KeyProvider key) { + return key.accept(new KeyProviderVisitor<>() { + @Override + public KeyManager[] visit(KeyPair keyPair) { + throw new SslConfigurationException("KeyPair is not supported by vault KMS yet"); + } + + @Override + public KeyManager[] visit(io.kroxylicious.proxy.config.tls.KeyStore keyStore) { + try { + if (keyStore.isPemType()) { + throw new SslConfigurationException("PEM is not supported by vault KMS yet"); + } + KeyStore store = KeyStore.getInstance(keyStore.getType()); + char[] storePassword = passwordOrNull(keyStore.storePasswordProvider()); + try (FileInputStream fileInputStream = new FileInputStream(keyStore.storeFile())) { + store.load(fileInputStream, storePassword); + } + KeyManagerFactory instance = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + char[] keyPassword = passwordOrNull(keyStore.keyPasswordProvider()); + keyPassword = keyPassword == null ? storePassword : keyPassword; + instance.init(store, keyPassword); + return instance.getKeyManagers(); + } + catch (Exception e) { + throw new SslConfigurationException(e); + } + } + + @Nullable + private static char[] passwordOrNull(PasswordProvider value) { + return Optional.ofNullable(value).map(PasswordProvider::getProvidedPassword).map(String::toCharArray).orElse(null); + } + }); + } + + @NonNull + private static SSLContext getSslContext(TrustManager[] trustManagers, KeyManager[] keyManagers) { + try { + if (trustManagers == null && keyManagers == null) { + return SSLContext.getDefault(); + } + SSLContext context = SSLContext.getInstance("TLS"); + context.init(keyManagers, trustManagers, new SecureRandom()); + return context; + } + catch (Exception e) { + throw new SslConfigurationException(e); + } + } + + @VisibleForTesting + static TrustManager[] getTrustManagers(TrustProvider trust) { + + return trust.accept(new TrustProviderVisitor<>() { + @Override + public TrustManager[] visit(TrustStore trustStore) { + if (trustStore.isPemType()) { + throw new SslConfigurationException("PEM trust not supported by vault yet"); + } + try { + KeyStore instance = KeyStore.getInstance(trustStore.getType()); + char[] charArray = trustStore.storePasswordProvider() != null ? trustStore.storePasswordProvider().getProvidedPassword().toCharArray() : null; + instance.load(new FileInputStream(trustStore.storeFile()), charArray); + TrustManagerFactory managerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + managerFactory.init(instance); + return managerFactory.getTrustManagers(); + } + catch (Exception e) { + throw new SslConfigurationException(e); + } + } + + @Override + public TrustManager[] visit(InsecureTls insecureTls) { + if (insecureTls.insecure()) { + return INSECURE_TRUST_MANAGERS; + } + else { + return getDefaultTrustManagers(); + } + } + + private static TrustManager[] getDefaultTrustManagers() { + try { + TrustManagerFactory factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init((KeyStore) null); + return factory.getTrustManagers(); + } + catch (Exception e) { + throw new SslConfigurationException(e); + } + } + }); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/config/SslConfigurationException.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/config/SslConfigurationException.java new file mode 100644 index 0000000000..2c5157cf20 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/config/SslConfigurationException.java @@ -0,0 +1,17 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.config; + +public class SslConfigurationException extends RuntimeException { + public SslConfigurationException(Exception cause) { + super(cause); + } + + public SslConfigurationException(String message) { + super(message); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DecryptRequest.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DecryptRequest.java new file mode 100644 index 0000000000..7f2e218960 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DecryptRequest.java @@ -0,0 +1,22 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +@SuppressWarnings("java:S6218") // we don't need DecryptRequest equality +public record DecryptRequest(@JsonProperty(value = "KeyId") @NonNull String keyId, + @JsonProperty(value = "CiphertextBlob") @NonNull byte[] ciphertextBlob) { + public DecryptRequest { + Objects.requireNonNull(keyId); + Objects.requireNonNull(ciphertextBlob); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DecryptResponse.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DecryptResponse.java new file mode 100644 index 0000000000..2226b8cb78 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DecryptResponse.java @@ -0,0 +1,25 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +@JsonIgnoreProperties(ignoreUnknown = true) +@SuppressWarnings("java:S6218") // we don't need DecryptResponse equality +public record DecryptResponse(@JsonProperty(value = "KeyId") @NonNull String keyId, + @JsonProperty(value = "Plaintext") @NonNull byte[] plaintext) { + + public DecryptResponse { + Objects.requireNonNull(keyId); + Objects.requireNonNull(plaintext); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DescribeKeyRequest.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DescribeKeyRequest.java new file mode 100644 index 0000000000..4b401cda34 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DescribeKeyRequest.java @@ -0,0 +1,20 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +public record DescribeKeyRequest(@JsonProperty(value = "KeyId") @NonNull String keyId) { + + public DescribeKeyRequest { + Objects.requireNonNull(keyId); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DescribeKeyResponse.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DescribeKeyResponse.java new file mode 100644 index 0000000000..f8e26049d8 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/DescribeKeyResponse.java @@ -0,0 +1,22 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record DescribeKeyResponse(@JsonProperty(value = "KeyMetadata") @NonNull KeyMetadata keyMetadata) { + + public DescribeKeyResponse { + Objects.requireNonNull(keyMetadata); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/ErrorResponse.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/ErrorResponse.java new file mode 100644 index 0000000000..319ac3eef7 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/ErrorResponse.java @@ -0,0 +1,44 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Locale; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +/** + * Encapsulates an AWS error response. + * + * @see https://docs.aws.amazon.com/kms/latest/APIReference/CommonErrors.html + * + * @param type type of error + * @param message associated error message + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record ErrorResponse(@JsonProperty(value = "__type") @NonNull String type, + @JsonProperty(value = "message") String message) { + public ErrorResponse { + Objects.requireNonNull(type); + } + + public boolean isNotFound() { + return (type().equalsIgnoreCase("NotFoundException") || + (type().equalsIgnoreCase("KMSInvalidStateException") && String.valueOf(message()).toLowerCase(Locale.ROOT).contains("is pending deletion"))); + } + + @Override + public String toString() { + return "ErrorResponse{" + + "type='" + type + '\'' + + ", message='" + message + '\'' + + '}'; + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/GenerateDataKeyRequest.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/GenerateDataKeyRequest.java new file mode 100644 index 0000000000..f21bd774b5 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/GenerateDataKeyRequest.java @@ -0,0 +1,22 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +public record GenerateDataKeyRequest(@JsonProperty(value = "KeyId") @NonNull String keyId, + @JsonProperty(value = "KeySpec") @NonNull String keySpec) { + + public GenerateDataKeyRequest { + Objects.requireNonNull(keyId); + Objects.requireNonNull(keySpec); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/GenerateDataKeyResponse.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/GenerateDataKeyResponse.java new file mode 100644 index 0000000000..fb8eae0204 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/GenerateDataKeyResponse.java @@ -0,0 +1,27 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +@JsonIgnoreProperties(ignoreUnknown = true) +@SuppressWarnings("java:S6218") // we don't need DecryptResponse equality +public record GenerateDataKeyResponse(@JsonProperty(value = "KeyId") @NonNull String keyId, + @JsonProperty(value = "CiphertextBlob") byte[] ciphertextBlob, + @JsonProperty(value = "Plaintext") byte[] plaintext) { + + public GenerateDataKeyResponse { + Objects.requireNonNull(keyId); + Objects.requireNonNull(ciphertextBlob); + Objects.requireNonNull(plaintext); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/KeyMetadata.java b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/KeyMetadata.java new file mode 100644 index 0000000000..79bb9bffaf --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/java/io/kroxylicious/kms/provider/aws/kms/model/KeyMetadata.java @@ -0,0 +1,23 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import edu.umd.cs.findbugs.annotations.NonNull; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KeyMetadata(@JsonProperty("KeyId") @NonNull String keyId, + @JsonProperty("Arn") @NonNull String arn) { + public KeyMetadata { + Objects.requireNonNull(keyId); + Objects.requireNonNull(arn); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/main/resources/META-INF/services/io.kroxylicious.kms.service.KmsService b/kroxylicious-kms-provider-aws-kms/src/main/resources/META-INF/services/io.kroxylicious.kms.service.KmsService new file mode 100644 index 0000000000..03300b1a97 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/main/resources/META-INF/services/io.kroxylicious.kms.service.KmsService @@ -0,0 +1,7 @@ +# +# Copyright Kroxylicious Authors. +# +# Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 +# + +io.kroxylicious.kms.provider.aws.kms.AwsKmsService \ No newline at end of file diff --git a/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdekSerdeTest.java b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdekSerdeTest.java new file mode 100644 index 0000000000..831a3b2363 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdekSerdeTest.java @@ -0,0 +1,60 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.nio.ByteBuffer; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import io.kroxylicious.kms.service.Serde; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AwsKmsEdekSerdeTest { + + private static final String KEY_REF = "1234abcd-12ab-34cd-56ef-1234567890ab"; + private final Serde serde = AwsKmsEdekSerde.instance(); + + @Test + void shouldRoundTrip() { + var edek = new AwsKmsEdek(KEY_REF, new byte[]{ 1, 2, 3 }); + var buf = ByteBuffer.allocate(serde.sizeOf(edek)); + serde.serialize(edek, buf); + buf.flip(); + var deserialized = serde.deserialize(buf); + assertThat(deserialized).isEqualTo(edek); + } + + @Test + void sizeOf() { + var edek = new AwsKmsEdek(KEY_REF, new byte[]{ 1 }); + var expectedSize = 1 + 1 + 36 + 1; + var size = serde.sizeOf(edek); + assertThat(size).isEqualTo(expectedSize); + } + + static Stream deserializeErrors() { + return Stream.of( + Arguments.of("wrong version", new byte[]{ 1 }), + Arguments.of("nokek", new byte[]{ 0, 0 }), + Arguments.of("noekekbytes", new byte[]{ 0, 3, 'A', 'B', 'C' })); + } + + @ParameterizedTest(name = "{0}") + @MethodSource + void deserializeErrors(String name, byte[] serializedBytes) { + var buf = ByteBuffer.wrap(serializedBytes); + assertThatThrownBy(() -> serde.deserialize(buf)) + .isInstanceOf(IllegalArgumentException.class); + } + +} diff --git a/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdekTest.java b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdekTest.java new file mode 100644 index 0000000000..94ad73d1f6 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsEdekTest.java @@ -0,0 +1,46 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class AwsKmsEdekTest { + @Test + @SuppressWarnings("java:S5853") + void equalsAndHashCode() { + var edek1 = new AwsKmsEdek("keyref", new byte[]{ (byte) 1, (byte) 2, (byte) 3 }); + var edek2 = new AwsKmsEdek("keyref", new byte[]{ (byte) 1, (byte) 2, (byte) 3 }); + var keyRefDiffer = new AwsKmsEdek("keyrefX", new byte[]{ (byte) 1, (byte) 2, (byte) 3 }); + var edekBytesDiffer = new AwsKmsEdek("keyref", new byte[]{ (byte) 1, (byte) 2, (byte) 4 }); + + assertThat(edek1) + .isEqualTo(edek1) + .isNotEqualTo(new Object()) + .isNotEqualTo(null); + + assertThat(edek1) + .isEqualTo(edek2) + .hasSameHashCodeAs(edek2); + assertThat(edek2).isEqualTo(edek1); + + assertThat(edek1) + .isNotEqualTo(keyRefDiffer) + .doesNotHaveSameHashCodeAs(keyRefDiffer); + + assertThat(edek1) + .isNotEqualTo(edekBytesDiffer) + .doesNotHaveSameHashCodeAs(edekBytesDiffer); + } + + @Test + void toStringFormation() { + var edek = new AwsKmsEdek("keyref", new byte[]{ (byte) 1, (byte) 2, (byte) 3 }); + assertThat(edek).hasToString("AwsKmsEdek{keyRef=keyref, edek=[1, 2, 3]}"); + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTest.java b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTest.java new file mode 100644 index 0000000000..d3197db8f0 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsKmsTest.java @@ -0,0 +1,191 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Base64; +import java.util.function.Consumer; + +import org.assertj.core.api.Assertions; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import io.kroxylicious.kms.provider.aws.kms.config.Config; +import io.kroxylicious.kms.service.DekPair; +import io.kroxylicious.kms.service.DestroyableRawSecretKey; +import io.kroxylicious.kms.service.KmsException; +import io.kroxylicious.kms.service.SecretKeyUtils; +import io.kroxylicious.kms.service.UnknownAliasException; +import io.kroxylicious.proxy.config.secret.InlinePassword; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit test for {@link AwsKms}. See also io.kroxylicious.kms.service.KmsIT. + */ +class AwsKmsTest { + + private static final Base64.Decoder BASE64_DECODER = Base64.getDecoder(); + + @Test + void resolveAlias() { + // example response from https://docs.aws.amazon.com/kms/latest/APIReference/API_DescribeKey.html + var response = """ + { + "KeyMetadata": { + "KeyId": "1234abcd-12ab-34cd-56ef-1234567890ab", + "Arn": "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab" + } + } + """; + var expectedKeyId = "1234abcd-12ab-34cd-56ef-1234567890ab"; + withMockAwsWithSingleResponse(response, kms -> { + var aliasStage = kms.resolveAlias("alias"); + assertThat(aliasStage) + .succeedsWithin(Duration.ofSeconds(5)) + .isEqualTo(expectedKeyId); + }); + } + + @Test + void resolveAliasNotFound() { + var response = """ + { + "__type": "NotFoundException", + "message": "Invalid keyId 'foo'"} + } + """; + withMockAwsWithSingleResponse(response, 400, kms -> { + var aliasStage = kms.resolveAlias("alias"); + assertThat(aliasStage) + .failsWithin(Duration.ofSeconds(5)) + .withThrowableThat() + .withCauseInstanceOf(UnknownAliasException.class) + .withMessageContaining("key 'alias' is not found"); + }); + } + + @Test + void resolveAliasInternalServerError() { + withMockAwsWithSingleResponse(null, 500, kms -> { + var aliasStage = kms.resolveAlias("alias"); + assertThat(aliasStage) + .failsWithin(Duration.ofSeconds(5)) + .withThrowableThat() + .withCauseInstanceOf(KmsException.class) + .withMessageContaining("Operation failed"); + }); + } + + @Test + void generateDekPair() { + // response from https://docs.aws.amazon.com/kms/latest/APIReference/API_GenerateDataKey.html + var response = """ + { + "CiphertextBlob": "AQEDAHjRYf5WytIc0C857tFSnBaPn2F8DgfmThbJlGfR8P3WlwAAAH4wfAYJKoZIhvcNAQcGoG8wbQIBADBoBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDEFogLqPWZconQhwHAIBEIA7d9AC7GeJJM34njQvg4Wf1d5sw0NIo1MrBqZa+YdhV8MrkBQPeac0ReRVNDt9qleAt+SHgIRF8P0H+7U=", + "KeyId": "arn:aws:kms:us-east-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + "Plaintext": "VdzKNHGzUAzJeRBVY+uUmofUGGiDzyB3+i9fVkh3piw=" + } + """; + var ciphertextBlobBytes = BASE64_DECODER.decode( + "AQEDAHjRYf5WytIc0C857tFSnBaPn2F8DgfmThbJlGfR8P3WlwAAAH4wfAYJKoZIhvcNAQcGoG8wbQIBADBoBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDEFogLqPWZconQhwHAIBEIA7d9AC7GeJJM34njQvg4Wf1d5sw0NIo1MrBqZa+YdhV8MrkBQPeac0ReRVNDt9qleAt+SHgIRF8P0H+7U="); + var plainTextBytes = BASE64_DECODER.decode("VdzKNHGzUAzJeRBVY+uUmofUGGiDzyB3+i9fVkh3piw="); + var expectedKey = DestroyableRawSecretKey.takeCopyOf(plainTextBytes, "AES"); + + withMockAwsWithSingleResponse(response, vaultKms -> { + var aliasStage = vaultKms.generateDekPair("alias"); + assertThat(aliasStage) + .succeedsWithin(Duration.ofSeconds(5)) + .extracting(DekPair::edek) + .isEqualTo(new AwsKmsEdek("alias", ciphertextBlobBytes)); + + assertThat(aliasStage) + .succeedsWithin(Duration.ofSeconds(5)) + .extracting(DekPair::dek) + .asInstanceOf(InstanceOfAssertFactories.type(DestroyableRawSecretKey.class)) + .matches(key -> SecretKeyUtils.same(key, expectedKey)); + }); + } + + @Test + void decryptEdek() { + var plainTextBytes = BASE64_DECODER.decode("VGhpcyBpcyBEYXkgMSBmb3IgdGhlIEludGVybmV0Cg=="); + // response from https://docs.aws.amazon.com/kms/latest/APIReference/API_Decrypt.html + var response = """ + { + "KeyId": "arn:aws:kms:us-west-2:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab", + "Plaintext": "VGhpcyBpcyBEYXkgMSBmb3IgdGhlIEludGVybmV0Cg==", + "EncryptionAlgorithm": "SYMMETRIC_DEFAULT" + }"""; + var expectedKey = DestroyableRawSecretKey.takeCopyOf(plainTextBytes, "AES"); + + withMockAwsWithSingleResponse(response, kms -> { + Assertions.assertThat(kms.decryptEdek(new AwsKmsEdek("kek", "unused".getBytes(StandardCharsets.UTF_8)))) + .succeedsWithin(Duration.ofSeconds(5)) + .asInstanceOf(InstanceOfAssertFactories.type(DestroyableRawSecretKey.class)) + .matches(key -> SecretKeyUtils.same(key, expectedKey)); + }); + } + + void withMockAwsWithSingleResponse(String response, Consumer consumer) { + withMockAwsWithSingleResponse(response, 200, consumer); + } + + void withMockAwsWithSingleResponse(String response, int statusCode, Consumer consumer) { + HttpHandler handler = statusCode >= 500 ? new ErrorResponse(statusCode) : new StaticResponse(statusCode, response); + HttpServer httpServer = httpServer(handler); + try { + var address = httpServer.getAddress(); + var awsAddress = "http://127.0.0.1:" + address.getPort(); + var config = new Config(URI.create(awsAddress), new InlinePassword("access"), new InlinePassword("secret"), "us-west-2", null); + var service = new AwsKmsService().buildKms(config); + consumer.accept(service); + } + finally { + httpServer.stop(0); + } + } + + public static HttpServer httpServer(HttpHandler handler) { + try { + HttpServer server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/", handler); + server.start(); + return server; + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + private record StaticResponse(int statusCode, String response) implements HttpHandler { + @Override + public void handle(HttpExchange e) throws IOException { + e.sendResponseHeaders(statusCode, response.length()); + try (var os = e.getResponseBody()) { + os.write(response.getBytes(StandardCharsets.UTF_8)); + } + } + } + + private record ErrorResponse(int statusCode) implements HttpHandler { + @Override + public void handle(HttpExchange e) throws IOException { + e.sendResponseHeaders(500, -1); + e.close(); + } + } + +} diff --git a/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsV4SigningHttpRequestBuilderTest.java b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsV4SigningHttpRequestBuilderTest.java new file mode 100644 index 0000000000..a124b159fe --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/AwsV4SigningHttpRequestBuilderTest.java @@ -0,0 +1,244 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import edu.umd.cs.findbugs.annotations.NonNull; + +import static org.assertj.core.api.Assertions.assertThat; + +class AwsV4SigningHttpRequestBuilderTest { + + private static final YAMLFactory YAML_FACTORY = new YAMLFactory(); + private static final ObjectMapper MAPPER = new ObjectMapper(YAML_FACTORY).registerModule(new JavaTimeModule()); + private static final String ACCESS_KEY = "access"; + private static final String SECRET_KEY = "secret"; + private static final String REGION = "us-east-1"; + private static final String SERVICE = "kms"; + public static final URI TEST_URI = URI.create("http://localhost:1234"); + + static Stream requestSigning() throws Exception { + try (var knownGoodYaml = AwsV4SigningHttpRequestBuilderTest.class.getResourceAsStream("/io/kroxylicious/kms/provider/aws/kms/known_good.yaml")) { + assertThat(knownGoodYaml).isNotNull(); + var parser = YAML_FACTORY.createParser(knownGoodYaml); + List testDefs = MAPPER.readValues(parser, TestDef.class).readAll(); + return testDefs.stream().map(td -> Arguments.of(td.testName(), td)); + } + } + + /** + * The test compares known-good signatures generated by Curl's AWS v4 signing + * support with the signature resulting from signing the same request with + * AwsV4SigningHttpRequestBuilder. + * + * @param testName test name + * @param testDef test definition + * @throws Exception exception + */ + @ParameterizedTest(name = "{0}") + @MethodSource + void requestSigning(String testName, TestDef testDef) throws Exception { + + var requestHeaderCatching = new RequestHeaderCatchingHandler(); + var server = httpServer(testDef.url.getPort(), requestHeaderCatching); + var client = HttpClient.newHttpClient(); + try { + var builder = AwsV4SigningHttpRequestBuilder.newBuilder(testDef.accessKeyId(), + testDef.secretAccessKey(), + testDef.region(), + testDef.service(), + testDef.requestTime()); + testDef.apply(builder); + var request = builder.build(); + + client.send(request, HttpResponse.BodyHandlers.discarding()); + var actualHeaders = requestHeaderCatching.getHeaders(); + + assertThat(actualHeaders) + .containsAllEntriesOf(testDef.expectedHeaders()); + } + finally { + server.stop(0); + } + } + + @Test + void copy() { + var original = createBuilder(TEST_URI); + var copy = original.copy(); + assertThat(copy) + .isNotEqualTo(original) + .isInstanceOf(original.getClass()); + + var req = copy.build(); + assertThat(req.uri()).isEqualTo(TEST_URI); + } + + @ParameterizedTest + @ValueSource(booleans = { true, false }) + void expectContinue(Boolean expectContinue) { + var r = createBuilder(TEST_URI).expectContinue(expectContinue).build(); + assertThat(r.expectContinue()).isEqualTo(expectContinue); + } + + @ParameterizedTest + @EnumSource(value = HttpClient.Version.class) + void version(HttpClient.Version version) { + var req = createBuilder(TEST_URI).version(version).build(); + assertThat(req.version()).contains(version); + } + + @Test + void timeout() { + var duration = Duration.ofMinutes(1); + var req = createBuilder(TEST_URI).timeout(duration).build(); + assertThat(req.timeout()).contains(duration); + } + + @Test + void setHeader() { + var req = createBuilder(TEST_URI).setHeader("foo", "bar").build(); + assertThat(req.headers().map()).containsEntry("foo", List.of("bar")); + } + + @Test + void headers() { + var req = createBuilder(TEST_URI).headers("foo", "bar", "coo", "car").build(); + assertThat(req.headers().map()) + .containsEntry("foo", List.of("bar")) + .containsEntry("coo", List.of("car")); + } + + @ParameterizedTest + @ValueSource(strings = { "GET", "POST", "PUT", "DELETE" }) + void methodCall(String method) { + var req = createBuilder(TEST_URI).method(method, HttpRequest.BodyPublishers.noBody()).build(); + assertThat(req.method()).isEqualTo(method); + } + + @ParameterizedTest + @ValueSource(strings = { "GET", "POST", "PUT", "DELETE" }) + void method(String method) { + var builder = createBuilder(TEST_URI); + + switch (method) { + case "GET": + builder.GET(); + break; + case "DELETE": + builder.DELETE(); + break; + case "POST": + builder.POST(HttpRequest.BodyPublishers.noBody()); + break; + case "PUT": + builder.PUT(HttpRequest.BodyPublishers.noBody()); + break; + } + assertThat(builder.build().method()).isEqualTo(method); + } + + static Stream hostHeader() { + return Stream.of( + Arguments.of("http implicit port", URI.create("http://localhost/foo"), "localhost"), + Arguments.of("http explicit default port", URI.create("http://localhost/foo:80"), "localhost"), + Arguments.of("https implicit port", URI.create("https://localhost/foo"), "localhost"), + Arguments.of("https explicit default port", URI.create("https://localhost:443/foo"), "localhost"), + Arguments.of("http non standard port", URI.create("http://localhost:8080/foo"), "localhost:8080"), + Arguments.of("https non standard port", URI.create("http://localhost:8443/foo"), "localhost:8443")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource + void hostHeader(String name, URI uri, String expected) { + var builder = ((AwsV4SigningHttpRequestBuilder) createBuilder(TEST_URI)); + assertThat(builder.getHostHeaderForSigning(uri)).isEqualTo(expected); + } + + @NonNull + private HttpRequest.Builder createBuilder(URI uri) { + var builder = AwsV4SigningHttpRequestBuilder.newBuilder(ACCESS_KEY, SECRET_KEY, REGION, SERVICE, Instant.ofEpochMilli(0)); + if (uri != null) { + builder.uri(uri); + } + return builder; + } + + record TestDef(String testName, Instant requestTime, URI url, String method, String accessKeyId, String secretAccessKey, String region, String service, + String data, Map> headers, Map> expectedHeaders) { + public void apply(HttpRequest.Builder builder) { + builder.uri(url()); + if (headers != null) { + headers.forEach((name, valueList) -> valueList.forEach(value -> builder.header(name, value))); + } + switch (method()) { + case "POST": + builder.POST(data == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(data())); + break; + case "GET": + builder.GET(); + break; + default: + throw new UnsupportedOperationException(method() + " is not supported."); + } + } + } + + private static HttpServer httpServer(int port, HttpHandler handler) { + try { + var server = HttpServer.create(new InetSocketAddress(port), 0); + server.createContext("/", handler); + server.start(); + return server; + } + catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static class RequestHeaderCatchingHandler implements HttpHandler { + private Headers headers; + + @Override + public void handle(HttpExchange exchange) throws IOException { + headers = exchange.getRequestHeaders(); + exchange.sendResponseHeaders(200, 0); + exchange.getResponseBody().close(); + } + + public Headers getHeaders() { + return headers; + } + } +} diff --git a/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/model/ErrorResponseTest.java b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/model/ErrorResponseTest.java new file mode 100644 index 0000000000..aa6fdaa514 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/test/java/io/kroxylicious/kms/provider/aws/kms/model/ErrorResponseTest.java @@ -0,0 +1,38 @@ +/* + * Copyright Kroxylicious Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ + +package io.kroxylicious.kms.provider.aws.kms.model; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + */ +class ErrorResponseTest { + + @Test + void notFound() { + var er = new ErrorResponse("NotFoundException", null); + assertThat(er.type()).isEqualTo("NotFoundException"); + assertThat(er.message()).isNull(); + assertThat(er.isNotFound()).isTrue(); + } + + @Test + void pendingDeleteIsTreatedAsNotFound() { + var er = new ErrorResponse("KMSInvalidStateException", "arn:aws:kms:us-east-1:000000000000:key/b3c06d76-25b1-41ad-b3d7-580ad1e58eeb is pending deletion."); + assertThat(er.isNotFound()).isTrue(); + } + + @Test + void accessDenied() { + var er = new ErrorResponse("AccessDeniedException", null); + assertThat(er.type()).isEqualTo("AccessDeniedException"); + assertThat(er.message()).isNull(); + assertThat(er.isNotFound()).isFalse(); + } +} \ No newline at end of file diff --git a/kroxylicious-kms-provider-aws-kms/src/test/resources/io/kroxylicious/kms/provider/aws/kms/input.yaml b/kroxylicious-kms-provider-aws-kms/src/test/resources/io/kroxylicious/kms/provider/aws/kms/input.yaml new file mode 100644 index 0000000000..13c22af566 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/test/resources/io/kroxylicious/kms/provider/aws/kms/input.yaml @@ -0,0 +1,91 @@ +# +# Copyright Kroxylicious Authors. +# +# Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 +# + +# This file defines the tests. aws_signing_v4_known_good_testdata_gen.sh accepts it as input and generates +# known_good.yaml as output. + +testName: post with data +url: http://localhost:4566 +method: POST +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +data: somedata +--- +testName: post with data with path +method: POST +url: http://localhost:4566/my/path +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +data: somedata +--- +testName: post with data with additional signed header +method: POST +url: http://localhost:4566 +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +data: somedata +headers: + X-Amz-Target: + - TrentService.DescribeKey +--- +testName: get +method: GET +url: http://localhost:4566 +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +--- +testName: get with query args +method: GET +url: http://localhost:4566/get?foo=bar +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +--- +testName: signature contains space normalized header value +# see https://github.com/aws/aws-sdk-java-v2/blob/26bb6dcf058b08f55665f931d02937238b00e576/core/auth/src/test/java/software/amazon/awssdk/auth/signer/Aws4SignerTest.java#L203 +method: GET +url: http://localhost:4566 +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +headers: + MyHeader: + - " leading and trailing white space stripped " +--- +testName: signature omits header with no value +method: GET +url: http://localhost:4566 +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +headers: + MyHeader: [] +#--- +#testName: signature handles header with more than one value +# AWS specification https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html does not define +# the reqyired behaviour. curl's implementation differs from the AWS test case. https://github.com/aws/aws-sdk-java-v2/blob/26bb6dcf058b08f55665f931d02937238b00e576/core/auth/src/test/java/software/amazon/awssdk/auth/signer/Aws4SignerTest.java#L203 +# Kroxylicious doesn't require headers with > 1 value, so let's ignore it. +#method: GET +#url: http://localhost:4566 +#accessKeyId: access +#secretAccessKey: secret +#region: us-east-1 +#service: kms +#headers: +# MyHeader: +# - first +# - second diff --git a/kroxylicious-kms-provider-aws-kms/src/test/resources/io/kroxylicious/kms/provider/aws/kms/known_good.yaml b/kroxylicious-kms-provider-aws-kms/src/test/resources/io/kroxylicious/kms/provider/aws/kms/known_good.yaml new file mode 100644 index 0000000000..78d04b0ab0 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/test/resources/io/kroxylicious/kms/provider/aws/kms/known_good.yaml @@ -0,0 +1,130 @@ +# +# Copyright Kroxylicious Authors. +# +# Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 +# + +# Known good test data for AWS v4 request signing + +testName: post with data +url: http://localhost:4566 +method: POST +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +data: somedata +requestTime: 1715950890 +expectedHeaders: + Host: + - localhost:4566 + Authorization: + - AWS4-HMAC-SHA256 Credential=access/20240517/us-east-1/kms/aws4_request, SignedHeaders=host;x-amz-date, Signature=d01abf110351d12d715fa037454491f580f6b92e70345f7c4d3af583bc6637e5 + X-Amz-Date: + - 20240517T130130Z +--- +testName: post with data with path +method: POST +url: http://localhost:4566/my/path +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +data: somedata +requestTime: 1715950890 +expectedHeaders: + Host: + - localhost:4566 + Authorization: + - AWS4-HMAC-SHA256 Credential=access/20240517/us-east-1/kms/aws4_request, SignedHeaders=host;x-amz-date, Signature=f682826f50818283d003f4ae917eb91579aa4bbfb318c8d449e503b0c71fad90 + X-Amz-Date: + - 20240517T130130Z +--- +testName: post with data with additional signed header +method: POST +url: http://localhost:4566 +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +data: somedata +headers: + X-Amz-Target: + - TrentService.DescribeKey +requestTime: 1715950890 +expectedHeaders: + Host: + - localhost:4566 + Authorization: + - AWS4-HMAC-SHA256 Credential=access/20240517/us-east-1/kms/aws4_request, SignedHeaders=host;x-amz-date;x-amz-target, Signature=eeeb906dbb1f67993eaf41af80cf180676ab90cdc6b298cfe00cdb04bce93007 + X-Amz-Date: + - 20240517T130130Z +--- +testName: get +method: GET +url: http://localhost:4566 +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +requestTime: 1715950890 +expectedHeaders: + Host: + - localhost:4566 + Authorization: + - AWS4-HMAC-SHA256 Credential=access/20240517/us-east-1/kms/aws4_request, SignedHeaders=host;x-amz-date, Signature=2c48184e1714b5e6798cbd15548eb75d16e373b25c337570c24dd6275d2db27b + X-Amz-Date: + - 20240517T130130Z +--- +testName: get with query args +method: GET +url: http://localhost:4566/get?foo=bar +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +requestTime: 1715950890 +expectedHeaders: + Host: + - localhost:4566 + Authorization: + - AWS4-HMAC-SHA256 Credential=access/20240517/us-east-1/kms/aws4_request, SignedHeaders=host;x-amz-date, Signature=4a52818fc91db8e5a0de0c4a0d02d327f406c75bb9725dbc84557d54c53e4925 + X-Amz-Date: + - 20240517T130130Z +--- +testName: signature contains space normalized header value +method: GET +url: http://localhost:4566 +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +headers: + MyHeader: + - ' leading and trailing white space stripped ' +requestTime: 1715950890 +expectedHeaders: + Host: + - localhost:4566 + Authorization: + - AWS4-HMAC-SHA256 Credential=access/20240517/us-east-1/kms/aws4_request, SignedHeaders=host;myheader;x-amz-date, Signature=c29127a534c9f583e9adbe23a52a8e081465dd0b893160810ca3ea6a55a628c9 + X-Amz-Date: + - 20240517T130130Z +--- +testName: signature omits header with no value +method: GET +url: http://localhost:4566 +accessKeyId: access +secretAccessKey: secret +region: us-east-1 +service: kms +headers: + MyHeader: [] +requestTime: 1715950890 +expectedHeaders: + Host: + - localhost:4566 + Authorization: + - AWS4-HMAC-SHA256 Credential=access/20240517/us-east-1/kms/aws4_request, SignedHeaders=host;x-amz-date, Signature=2c48184e1714b5e6798cbd15548eb75d16e373b25c337570c24dd6275d2db27b + X-Amz-Date: + - 20240517T130130Z diff --git a/kroxylicious-kms-provider-aws-kms/src/test/scripts/aws_signing_v4_known_good_testdata_gen.sh b/kroxylicious-kms-provider-aws-kms/src/test/scripts/aws_signing_v4_known_good_testdata_gen.sh new file mode 100755 index 0000000000..2da4990da3 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/test/scripts/aws_signing_v4_known_good_testdata_gen.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# +# Copyright Kroxylicious Authors. +# +# Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 +# + +# Generates known good test data for the AWS Request Signing Tests +# Usage: aws_signing_v4_known_good_testdata_gen.sh > test/resources/ + +set -euo pipefail + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +. "${SCRIPT_DIR}/../../../../scripts/common.sh" +ECHOING_HTTP_SERVER=${SCRIPT_DIR}/echoing_http_server.py +CURL=$(resolveCommand curl) +INPUT_YAML=${1?Usage $0 } + +function awaitPortOpen() { + local PORT + PORT=$1 + while ! nc -z localhost ${PORT} 1>/dev/null 2>/dev/null; do + sleep 0.1 + done +} + +function startServer() { + local PORT + PORT=$1 + $ECHOING_HTTP_SERVER ${PORT} & + SERVER_PID=$! + trap "kill ${SERVER_PID} || true" EXIT + awaitPortOpen ${PORT} + echo Server started on port ${PORT} >/dev/stderr +} + +function invoke_curl() { + ${CURL} --silent "${@}" +} + +cat << EOF +# +# Copyright Kroxylicious Authors. +# +# Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 +# + +# Known good test data for AWS v4 request signing + +EOF + +echo Starting server 1>/dev/stderr +startServer 4566 + +FIRST=true +IFS='|' +yq eval-all '. | [[ +. | @json, +.url, +"--user", +.accessKeyId + ":" + .secretAccessKey, +"--aws-sigv4", + "aws:amz:" + .region + ":" + .service, +"--request", +.method, +((.data | "--data-raw" + "|" + .) // null), +((.headers[] | ((.[] | "--header" + "|" + ((parent | key) + ": " + .))) + // ("--header" + "|" + key + ":")) // null) +] +| del( .[] | select(. == null)) +| join("|") ] +| join("\n") +' ${INPUT_YAML} | while read -ra ALL; do + TEST_DEF_JSON=${ALL[0]} + CURL_ARGS=(${ALL[@]:1}) + if [[ ${FIRST} == "false" ]]; then + echo --- + fi + invoke_curl ${CURL_ARGS[@]} | jq --argjson testDef "${TEST_DEF_JSON}" \ + '$testDef * . | del(..|select(. == ""))' | yq -P + FIRST=false +done + + +invoke_curl http://localhost:4566/bye 1>/dev/null + +echo "All done" >/dev/stderr diff --git a/kroxylicious-kms-provider-aws-kms/src/test/scripts/echoing_http_server.py b/kroxylicious-kms-provider-aws-kms/src/test/scripts/echoing_http_server.py new file mode 100755 index 0000000000..a4cfef5606 --- /dev/null +++ b/kroxylicious-kms-provider-aws-kms/src/test/scripts/echoing_http_server.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# +# Copyright Kroxylicious Authors. +# +# Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 +# + +# An HTTP server that echos headers sent by the client back to the response. + +import http.server as SimpleHTTPServer +from socketserver import TCPServer +import sys +import json +import dateutil.parser + +try: + port = int(sys.argv[1]) +except: + port = 8080 + +class GetHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): + + interest = {"Host", "Authorization", "X-Amz-Date"} + def do_GET(self): + self.handle_bye() + self.echo_response() + + def do_POST(self): + self.handle_bye() + self.echo_response() + + def echo_response(self): + headers = {i[0]: [i[1]] for i in self.headers.items() if i[0] in self.interest} + request_time = int(dateutil.parser.isoparse(headers["X-Amz-Date"][0]).timestamp()) + resp = {"requestTime": request_time, + "expectedHeaders": headers} + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(resp).encode('utf-8')) + + def log_request(self, code='-', size='-'): + """request logging is not desired""" + pass + + def handle_bye(self): + if self.path == '/bye': + self.server.socket.close() + sys.exit(0) + + +TCPServer.allow_reuse_address = True +httpd = TCPServer(('', port), GetHandler) + +httpd.serve_forever() diff --git a/kroxylicious-kms-provider-hashicorp-vault-test-support/pom.xml b/kroxylicious-kms-provider-hashicorp-vault-test-support/pom.xml index fe7acba74f..5b56518c4d 100644 --- a/kroxylicious-kms-provider-hashicorp-vault-test-support/pom.xml +++ b/kroxylicious-kms-provider-hashicorp-vault-test-support/pom.xml @@ -18,6 +18,7 @@ kroxylicious-kms-provider-hashicorp-vault-test-support + HashiCorp Vault KMS test support Test support code for modules testing the HashiCorp Vault KMS diff --git a/kroxylicious-kms-provider-hashicorp-vault/src/main/java/io/kroxylicious/kms/provider/hashicorp/vault/config/JdkTls.java b/kroxylicious-kms-provider-hashicorp-vault/src/main/java/io/kroxylicious/kms/provider/hashicorp/vault/config/JdkTls.java index 89bbb1cb6b..005bacd6a8 100644 --- a/kroxylicious-kms-provider-hashicorp-vault/src/main/java/io/kroxylicious/kms/provider/hashicorp/vault/config/JdkTls.java +++ b/kroxylicious-kms-provider-hashicorp-vault/src/main/java/io/kroxylicious/kms/provider/hashicorp/vault/config/JdkTls.java @@ -37,11 +37,11 @@ import edu.umd.cs.findbugs.annotations.Nullable; /** - * Builds a JDK SSLContext used for vault communicateion. + * Encapsulates parameters for an TLS connection with Vault. * * @param tls tls configuration * - * TODO ability to restrict by TLS protocol and cipher suite. + * TODO ability to restrict by TLS protocol and cipher suite (#1006) */ public record JdkTls(Tls tls) { diff --git a/kroxylicious-kms-provider-hashicorp-vault/src/test/java/io/kroxylicious/kms/provider/hashicorp/vault/VaultKmsTest.java b/kroxylicious-kms-provider-hashicorp-vault/src/test/java/io/kroxylicious/kms/provider/hashicorp/vault/VaultKmsTest.java index 1a107615fd..c7d7bb64ee 100644 --- a/kroxylicious-kms-provider-hashicorp-vault/src/test/java/io/kroxylicious/kms/provider/hashicorp/vault/VaultKmsTest.java +++ b/kroxylicious-kms-provider-hashicorp-vault/src/test/java/io/kroxylicious/kms/provider/hashicorp/vault/VaultKmsTest.java @@ -10,7 +10,6 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.net.URI; -import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; @@ -24,7 +23,6 @@ import javax.net.ssl.SSLContext; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -79,7 +77,8 @@ void resolveAlias() { } """; withMockVaultWithSingleResponse(response, vaultKms -> { - Assertions.assertThat(vaultKms.resolveAlias("alias")).succeedsWithin(Duration.ofSeconds(5)) + assertThat(vaultKms.resolveAlias("alias")) + .succeedsWithin(Duration.ofSeconds(5)) .isEqualTo("resolved"); }); } @@ -97,14 +96,16 @@ void generateDekPair() { String plaintext = "dGhlIHF1aWNrIGJyb3duIGZveAo="; String ciphertext = "vault:v1:abcdefgh"; byte[] decoded = Base64.getDecoder().decode(plaintext); - String response = "{\n" + - " \"data\": {\n" + - " \"plaintext\": \"" + plaintext + "\",\n" + - " \"ciphertext\": \"" + ciphertext + "\"\n" + - " }\n" + - "}\n"; + var response = """ + { + "data": { + "plaintext": "%s", + "ciphertext": "%s" + } + } + """.formatted(plaintext, ciphertext); withMockVaultWithSingleResponse(response, vaultKms -> { - Assertions.assertThat(vaultKms.generateDekPair("alias")).succeedsWithin(Duration.ofSeconds(5)) + assertThat(vaultKms.generateDekPair("alias")).succeedsWithin(Duration.ofSeconds(5)) .matches(dekPair -> Objects.equals(dekPair.edek(), new VaultEdek("alias", ciphertext.getBytes(StandardCharsets.UTF_8)))) .matches(dekPair -> SecretKeyUtils.same((DestroyableRawSecretKey) dekPair.dek(), DestroyableRawSecretKey.takeCopyOf(decoded, "AES"))); }); @@ -124,13 +125,15 @@ void decryptEdek() { byte[] edekBytes = Base64.getDecoder().decode(edek); String plaintext = "qWruWwlmc7USk6uP41LZBs+gLVfkFWChb+jKivcWK0c="; byte[] plaintextBytes = Base64.getDecoder().decode(plaintext); - String response = "{\n" + - " \"data\": {\n" + - " \"plaintext\": \"" + plaintext + "\"\n" + - " }\n" + - "}\n"; + var response = """ + { + "data": { + "plaintext": "%s" + } + } + """.formatted(plaintext); withMockVaultWithSingleResponse(response, vaultKms -> { - Assertions.assertThat(vaultKms.decryptEdek(new VaultEdek("kek", edekBytes))).succeedsWithin(Duration.ofSeconds(5)) + assertThat(vaultKms.decryptEdek(new VaultEdek("kek", edekBytes))).succeedsWithin(Duration.ofSeconds(5)) .isInstanceOf(DestroyableRawSecretKey.class) .matches(key -> SecretKeyUtils.same((DestroyableRawSecretKey) key, DestroyableRawSecretKey.takeCopyOf(plaintextBytes, "AES"))); }); @@ -142,7 +145,7 @@ void testConnectionTimeout() throws NoSuchAlgorithmException { Duration timeout = Duration.ofMillis(500); VaultKms kms = new VaultKms(uri, "token", timeout, null); SSLContext sslContext = SSLContext.getDefault(); - HttpClient client = kms.createClient(sslContext); + var client = kms.createClient(sslContext); assertThat(client.connectTimeout()).hasValue(timeout); } @@ -231,7 +234,7 @@ private void assertReusesConnectionsOn404(Consumer consumer) { for (int i = 0; i < 5; i++) { consumer.accept(service); } - assertThat(handler.remotePorts.size()).isEqualTo(1); + assertThat(handler.remotePorts).hasSize(1); } finally { httpServer.stop(0); diff --git a/kroxylicious-kms-provider-kroxylicious-inmemory-test-support/pom.xml b/kroxylicious-kms-provider-kroxylicious-inmemory-test-support/pom.xml index d5b194aab7..fda4660133 100644 --- a/kroxylicious-kms-provider-kroxylicious-inmemory-test-support/pom.xml +++ b/kroxylicious-kms-provider-kroxylicious-inmemory-test-support/pom.xml @@ -18,6 +18,7 @@ kroxylicious-kms-provider-kroxylicious-inmemory-test-support + InMemory KMS test support Test support code for modules testing the InMemory KMS diff --git a/pom.xml b/pom.xml index e298532565..928ef8cb6c 100644 --- a/pom.xml +++ b/pom.xml @@ -379,6 +379,8 @@ kroxylicious-kafka-message-tools kroxylicious-kms kroxylicious-kms-test-support + kroxylicious-kms-provider-aws-kms + kroxylicious-kms-provider-aws-kms-test-support kroxylicious-kms-provider-kroxylicious-inmemory kroxylicious-kms-provider-kroxylicious-inmemory-test-support kroxylicious-kms-provider-hashicorp-vault