diff --git a/src/main/java/software/amazon/encryption/s3/materials/KmsContentAlgorithm.java b/src/main/java/software/amazon/encryption/s3/materials/KmsContentAlgorithm.java new file mode 100644 index 000000000..18b34f512 --- /dev/null +++ b/src/main/java/software/amazon/encryption/s3/materials/KmsContentAlgorithm.java @@ -0,0 +1,50 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.encryption.s3.materials; + +import software.amazon.encryption.s3.S3EncryptionClientException; +import software.amazon.encryption.s3.algorithms.AlgorithmSuite; + +/** + * Content-encryption algorithm handling shared by the Kms+Context decrypt paths of + * {@link KmsKeyring} and {@link KmsDiscoveryKeyring}. + */ +final class KmsContentAlgorithm { + + private static final String ENCRYPTION_CONTEXT_ALGORITHM_KEY = "aws:x-amz-cek-alg"; + + private KmsContentAlgorithm() { + } + + /** + * The stored content-encryption algorithm (KMS-authenticated) MUST match the algorithm suite + * selected from content metadata (unauthenticated). + */ + static void validate(DecryptionMaterials materials) { + String storedContentAlgorithm = materials.encryptionContext().get(ENCRYPTION_CONTEXT_ALGORITHM_KEY); + if (storedContentAlgorithm != null + && !storedContentAlgorithm.equals(forSuite(materials.algorithmSuite()))) { + throw new S3EncryptionClientException(String.format( + "The content encryption algorithm in the KMS-authenticated encryption context (%s) does not " + + "match the algorithm suite selected for decryption (%s).", + storedContentAlgorithm, materials.algorithmSuite().name())); + } + } + + /** + * Return content-encryption algorithm for a given AlgorithmSuite. A CTR suite returns the value of its + * GCM counterpart, because ranged gets decrypt a GCM object with the CTR suite. + */ + static String forSuite(AlgorithmSuite algorithmSuite) { + switch (algorithmSuite) { + case ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY: + case ALG_AES_256_CTR_HKDF_SHA512_COMMIT_KEY: + return AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.idAsString(); + case ALG_AES_256_GCM_IV12_TAG16_NO_KDF: + case ALG_AES_256_CTR_IV16_TAG16_NO_KDF: + return AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName(); + default: + return algorithmSuite.cipherName(); + } + } +} diff --git a/src/main/java/software/amazon/encryption/s3/materials/KmsDiscoveryKeyring.java b/src/main/java/software/amazon/encryption/s3/materials/KmsDiscoveryKeyring.java index 54b18bc29..37945ec4d 100644 --- a/src/main/java/software/amazon/encryption/s3/materials/KmsDiscoveryKeyring.java +++ b/src/main/java/software/amazon/encryption/s3/materials/KmsDiscoveryKeyring.java @@ -112,6 +112,8 @@ public byte[] decryptDataKey(DecryptionMaterials materials, byte[] encryptedData throw new S3EncryptionClientException("Provided encryption context does not match information retrieved from S3"); } + KmsContentAlgorithm.validate(materials); + DecryptRequest request = DecryptRequest.builder() .encryptionContext(materials.encryptionContext()) .ciphertextBlob(SdkBytes.fromByteArray(encryptedDataKey)) diff --git a/src/main/java/software/amazon/encryption/s3/materials/KmsKeyring.java b/src/main/java/software/amazon/encryption/s3/materials/KmsKeyring.java index b52566982..5a6f04008 100644 --- a/src/main/java/software/amazon/encryption/s3/materials/KmsKeyring.java +++ b/src/main/java/software/amazon/encryption/s3/materials/KmsKeyring.java @@ -251,6 +251,8 @@ public byte[] decryptDataKey(DecryptionMaterials materials, byte[] encryptedData throw new S3EncryptionClientException("Provided encryption context does not match information retrieved from S3"); } + KmsContentAlgorithm.validate(materials); + //= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context //# To attempt to decrypt a particular [encrypted data key](../structures.md#encrypted-data-key), the KmsKeyring //# MUST call [AWS KMS Decrypt](https://docs.aws.amazon.com/kms/latest/APIReference/API_Decrypt.html) with the configured AWS KMS client. diff --git a/src/test/java/software/amazon/encryption/s3/materials/KmsContentAlgorithmTest.java b/src/test/java/software/amazon/encryption/s3/materials/KmsContentAlgorithmTest.java new file mode 100644 index 000000000..3c9800818 --- /dev/null +++ b/src/test/java/software/amazon/encryption/s3/materials/KmsContentAlgorithmTest.java @@ -0,0 +1,65 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +package software.amazon.encryption.s3.materials; + +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 software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.encryption.s3.algorithms.AlgorithmSuite; + +import java.util.Collections; +import java.util.Map; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class KmsContentAlgorithmTest { + + private static Stream contentAlgorithmBySuite() { + return Stream.of( + Arguments.of(AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, "115"), + Arguments.of(AlgorithmSuite.ALG_AES_256_CTR_HKDF_SHA512_COMMIT_KEY, "115"), + Arguments.of(AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, "AES/GCM/NoPadding"), + Arguments.of(AlgorithmSuite.ALG_AES_256_CTR_IV16_TAG16_NO_KDF, "AES/GCM/NoPadding"), + Arguments.of(AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF, "AES/CBC/PKCS5Padding")); + } + + @ParameterizedTest + @MethodSource("contentAlgorithmBySuite") + public void forSuiteReturnsStoredContentAlgorithm(AlgorithmSuite suite, String expected) { + assertEquals(expected, KmsContentAlgorithm.forSuite(suite)); + } + + @ParameterizedTest + @MethodSource("contentAlgorithmBySuite") + public void validateAcceptsMatchingContentAlgorithm(AlgorithmSuite suite, String storedContentAlgorithm) { + DecryptionMaterials materials = materials(suite, + Collections.singletonMap("aws:x-amz-cek-alg", storedContentAlgorithm)); + assertDoesNotThrow(() -> KmsContentAlgorithm.validate(materials)); + } + + @ParameterizedTest + @EnumSource(AlgorithmSuite.class) + public void validateSkipsWhenContentAlgorithmIsAbsent(AlgorithmSuite suite) { + DecryptionMaterials materials = materials(suite, + Collections.singletonMap("kms_cmk_id", "arn:aws:kms:us-west-2:111122223333:key/example")); + assertDoesNotThrow(() -> KmsContentAlgorithm.validate(materials)); + } + + @Test + public void forSuiteCoversEveryAlgorithmSuite() { + assertEquals(AlgorithmSuite.values().length, contentAlgorithmBySuite().count()); + } + + private static DecryptionMaterials materials(AlgorithmSuite suite, Map encryptionContext) { + return DecryptionMaterials.builder() + .s3Request(GetObjectRequest.builder().bucket("bucket").key("key").build()) + .algorithmSuite(suite) + .encryptionContext(encryptionContext) + .build(); + } +} diff --git a/src/test/java/software/amazon/encryption/s3/materials/KmsDiscoveryKeyringTest.java b/src/test/java/software/amazon/encryption/s3/materials/KmsDiscoveryKeyringTest.java index 3d6362f7e..3e848f658 100644 --- a/src/test/java/software/amazon/encryption/s3/materials/KmsDiscoveryKeyringTest.java +++ b/src/test/java/software/amazon/encryption/s3/materials/KmsDiscoveryKeyringTest.java @@ -11,17 +11,25 @@ import com.amazonaws.services.s3.model.EncryptedPutObjectRequest; import com.amazonaws.services.s3.model.EncryptionMaterialsProvider; import com.amazonaws.services.s3.model.KMSEncryptionMaterialsProvider; +import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.RetryingTest; import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.kms.KmsClient; +import software.amazon.awssdk.services.kms.model.DecryptRequest; +import software.amazon.awssdk.services.kms.model.DecryptResponse; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectResponse; import software.amazon.encryption.s3.CommitmentPolicy; import software.amazon.encryption.s3.S3EncryptionClient; import software.amazon.encryption.s3.S3EncryptionClientException; +import software.amazon.encryption.s3.algorithms.AlgorithmSuite; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -29,6 +37,9 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static software.amazon.encryption.s3.S3EncryptionClient.withAdditionalConfiguration; import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.appendTestSuffix; import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.deleteObject; @@ -237,4 +248,39 @@ public void testKmsContextV3DiscoveryEncryptFails() { } } + // The data key was wrapped for a GCM object, so the KMS-authenticated encryption context carries + // aws:x-amz-cek-alg = AES/GCM/NoPadding, while the decryption materials select the CBC suite. + @Test + public void kmsContextDiscoveryDecryptFailsWhenAuthenticatedCekAlgDoesNotMatchSelectedSuite() { + Map storedEncryptionContext = new HashMap<>(); + storedEncryptionContext.put("kms_cmk_id", "arn:aws:kms:us-west-2:111122223333:key/example"); + storedEncryptionContext.put("aws:x-amz-cek-alg", AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName()); + + KmsClient kmsClient = mock(KmsClient.class); + when(kmsClient.decrypt(argThat((DecryptRequest request) -> + storedEncryptionContext.equals(request.encryptionContext())))) + .thenReturn(DecryptResponse.builder() + .plaintext(SdkBytes.fromByteArray(new byte[32])) + .build()); + + KmsDiscoveryKeyring keyring = KmsDiscoveryKeyring.builder() + .kmsClient(kmsClient) + .build(); + + DecryptionMaterials materials = DecryptionMaterials.builder() + .s3Request(GetObjectRequest.builder().bucket("bucket").key("key").build()) + .algorithmSuite(AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF) + .encryptionContext(storedEncryptionContext) + .build(); + + EncryptedDataKey edk = EncryptedDataKey.builder() + .keyProviderId(S3Keyring.KEY_PROVIDER_ID) + .keyProviderInfo("kms+context") + .encryptedDataKey(new byte[]{1, 2, 3}) + .build(); + + assertThrows(S3EncryptionClientException.class, + () -> keyring.onDecrypt(materials, Collections.singletonList(edk))); + } + } diff --git a/src/test/java/software/amazon/encryption/s3/materials/KmsKeyringTest.java b/src/test/java/software/amazon/encryption/s3/materials/KmsKeyringTest.java index 3a99210dc..9b5efe1f4 100644 --- a/src/test/java/software/amazon/encryption/s3/materials/KmsKeyringTest.java +++ b/src/test/java/software/amazon/encryption/s3/materials/KmsKeyringTest.java @@ -2,13 +2,31 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.encryption.s3.materials; +import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.RetryingTest; +import software.amazon.awssdk.core.SdkBytes; +import software.amazon.awssdk.services.kms.KmsClient; +import software.amazon.awssdk.services.kms.model.DecryptRequest; +import software.amazon.awssdk.services.kms.model.DecryptResponse; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.encryption.s3.S3EncryptionClientException; +import software.amazon.encryption.s3.algorithms.AlgorithmSuite; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class KmsKeyringTest { + private static final String KMS_KEY_ID = "arn:aws:kms:us-west-2:111122223333:key/example"; + @RetryingTest(3) public void buildKmsKeyringWithNullSecureRandomFails() { assertThrows(S3EncryptionClientException.class, () -> KmsKeyring.builder().secureRandom(null)); @@ -19,4 +37,83 @@ public void buildKmsKeyringWithNullDataKeyGeneratorFails() { assertThrows(S3EncryptionClientException.class, () -> KmsKeyring.builder().dataKeyGenerator(null)); } + // The data key was wrapped for a GCM object, so the KMS-authenticated encryption context carries + // aws:x-amz-cek-alg = AES/GCM/NoPadding. The decryption materials select the CBC suite (as they would + // if the content-algorithm metadata read CBC). The authenticated content algorithm and the selected + // suite disagree, so onDecrypt must fail rather than return the data key. + @Test + public void kmsContextDecryptFailsWhenAuthenticatedCekAlgDoesNotMatchSelectedSuite() { + Map storedEncryptionContext = new HashMap<>(); + storedEncryptionContext.put("kms_cmk_id", KMS_KEY_ID); + storedEncryptionContext.put("aws:x-amz-cek-alg", AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName()); + + // Releases the data key only for the encryption context it was wrapped under. + KmsClient kmsClient = mock(KmsClient.class); + when(kmsClient.decrypt(argThat((DecryptRequest request) -> + storedEncryptionContext.equals(request.encryptionContext())))) + .thenReturn(DecryptResponse.builder() + .keyId(KMS_KEY_ID) + .plaintext(SdkBytes.fromByteArray(new byte[32])) + .build()); + + KmsKeyring keyring = KmsKeyring.builder() + .kmsClient(kmsClient) + .wrappingKeyId(KMS_KEY_ID) + .build(); + + DecryptionMaterials materials = DecryptionMaterials.builder() + .s3Request(GetObjectRequest.builder().bucket("bucket").key("key").build()) + .algorithmSuite(AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF) + .encryptionContext(storedEncryptionContext) + .build(); + + EncryptedDataKey edk = EncryptedDataKey.builder() + .keyProviderId(S3Keyring.KEY_PROVIDER_ID) + .keyProviderInfo("kms+context") + .encryptedDataKey(new byte[]{1, 2, 3}) + .build(); + + S3EncryptionClientException exception = assertThrows(S3EncryptionClientException.class, + () -> keyring.onDecrypt(materials, Collections.singletonList(edk))); + assertTrue(exception.getMessage().contains("AES/GCM/NoPadding")); + assertTrue(exception.getMessage().contains("ALG_AES_256_CBC_IV16_NO_KDF")); + } + + // A ranged get selects the CTR suite for an object sealed under GCM, so the stored GCM content + // algorithm must be accepted for the CTR suite. + @Test + public void kmsContextDecryptSucceedsForCtrSuiteOfGcmObject() { + Map storedEncryptionContext = new HashMap<>(); + storedEncryptionContext.put("kms_cmk_id", KMS_KEY_ID); + storedEncryptionContext.put("aws:x-amz-cek-alg", AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName()); + + KmsClient kmsClient = mock(KmsClient.class); + when(kmsClient.decrypt(argThat((DecryptRequest request) -> + storedEncryptionContext.equals(request.encryptionContext())))) + .thenReturn(DecryptResponse.builder() + .keyId(KMS_KEY_ID) + .plaintext(SdkBytes.fromByteArray(new byte[32])) + .build()); + + KmsKeyring keyring = KmsKeyring.builder() + .kmsClient(kmsClient) + .wrappingKeyId(KMS_KEY_ID) + .build(); + + DecryptionMaterials materials = DecryptionMaterials.builder() + .s3Request(GetObjectRequest.builder().bucket("bucket").key("key").build()) + .algorithmSuite(AlgorithmSuite.ALG_AES_256_CTR_IV16_TAG16_NO_KDF) + .encryptionContext(storedEncryptionContext) + .build(); + + EncryptedDataKey edk = EncryptedDataKey.builder() + .keyProviderId(S3Keyring.KEY_PROVIDER_ID) + .keyProviderInfo("kms+context") + .encryptedDataKey(new byte[]{1, 2, 3}) + .build(); + + assertArrayEquals(new byte[32], + keyring.onDecrypt(materials, Collections.singletonList(edk)).plaintextDataKey()); + } + }