From 29a455b2339f240ccb98f4a3742451498b5f28f3 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Mon, 26 Oct 2026 21:18:03 +0000 Subject: [PATCH 1/4] fix(keyring): validate content algorithm matches selected suite on decrypt The KMS+Context and RSA-OAEP decrypt paths selected the content algorithm suite from the object's content metadata without checking it against the algorithm this data key was bound to. A mismatch (for example a GCM-sealed object whose content-algorithm metadata reads CBC) was accepted, so the object could be processed under a different suite than it was written with. KmsKeyring now rejects a stored aws:x-amz-cek-alg that does not match the selected suite before calling KMS Decrypt. RsaKeyring now accepts only GCM-family selected suites for the RSA-OAEP pseudo-data-key binding. Both preserve the GCM->CTR equivalence used by ranged gets. --- .../encryption/s3/materials/KmsKeyring.java | 29 ++++++ .../encryption/s3/materials/RsaKeyring.java | 23 +++-- .../s3/materials/KmsKeyringTest.java | 56 +++++++++++ .../s3/materials/RsaKeyringTest.java | 99 +++++++++++++++++++ 4 files changed, 201 insertions(+), 6 deletions(-) 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..9d133a47a 100644 --- a/src/main/java/software/amazon/encryption/s3/materials/KmsKeyring.java +++ b/src/main/java/software/amazon/encryption/s3/materials/KmsKeyring.java @@ -26,6 +26,7 @@ import software.amazon.awssdk.services.s3.model.S3Request; import software.amazon.encryption.s3.S3EncryptionClient; import software.amazon.encryption.s3.S3EncryptionClientException; +import software.amazon.encryption.s3.algorithms.AlgorithmSuite; import software.amazon.encryption.s3.internal.ApiNameVersion; //= specification/s3-encryption/materials/s3-kms-keyring.md#interface @@ -251,6 +252,17 @@ public byte[] decryptDataKey(DecryptionMaterials materials, byte[] encryptedData throw new S3EncryptionClientException("Provided encryption context does not match information retrieved from S3"); } + // The content encryption algorithm is part of the KMS-authenticated encryption context, while the + // algorithm suite is selected from the (unauthenticated) content metadata. The two must agree; if + // they do not, the content metadata does not correspond to how the object was sealed, so the data + // key is not released for a mismatched suite. + String storedContentAlgorithm = materials.encryptionContext().get(ENCRYPTION_CONTEXT_ALGORITHM_KEY); + if (storedContentAlgorithm != null + && !storedContentAlgorithm.equals(expectedContentAlgorithm(materials.algorithmSuite()))) { + throw new S3EncryptionClientException("The content encryption algorithm in the KMS-authenticated " + + "encryption context does not match the algorithm suite selected for decryption."); + } + //= 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. @@ -313,6 +325,23 @@ protected Map decryptDataKeyStrategies() { return decryptDataKeyStrategies; } + // The canonical value the content-encryption algorithm (aws:x-amz-cek-alg) holds in the stored encryption + // context for a given selected algorithm suite. The committing GCM/CTR pair share the committing suite ID, + // and the non-committing GCM/CTR pair share GCM's cipher name, because a ranged get selects the CTR variant + // of an object that was sealed under its GCM counterpart. + private static String expectedContentAlgorithm(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(); + } + } + public static class Builder extends S3Keyring.Builder { private KmsClient _kmsClient; private String _wrappingKeyId; diff --git a/src/main/java/software/amazon/encryption/s3/materials/RsaKeyring.java b/src/main/java/software/amazon/encryption/s3/materials/RsaKeyring.java index 74cb66a92..ba4a67bc3 100644 --- a/src/main/java/software/amazon/encryption/s3/materials/RsaKeyring.java +++ b/src/main/java/software/amazon/encryption/s3/materials/RsaKeyring.java @@ -158,13 +158,24 @@ private byte[] parsePseudoDataKey(DecryptionMaterials materials, byte[] pseudoDa throw new S3EncryptionClientException("Invalid key length (" + dataKeyLengthBytes + ") in encrypted data key"); } + // The content algorithm sealed inside the pseudo data key must match the algorithm suite selected + // for decryption. RSA-OAEP-SHA1 objects are only ever written with a GCM-family content suite, so + // the committing GCM/CTR pair maps to the committing suite ID (115) and the non-committing GCM/CTR + // pair maps to GCM's cipher name. Any other selected suite (e.g. CBC) does not correspond to how + // the data key was wrapped and is not accepted. byte[] expectedDataCipherAlg; - // For V3 committed algorithms (both GCM 115 and CTR 116), use GCM's suite ID string (115/0x0073) - if (materials.algorithmSuite().id() == AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.id() || - materials.algorithmSuite().id() == AlgorithmSuite.ALG_AES_256_CTR_HKDF_SHA512_COMMIT_KEY.id()) { - expectedDataCipherAlg = AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.idAsString().getBytes(StandardCharsets.UTF_8); - } else { - expectedDataCipherAlg = AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName().getBytes(StandardCharsets.UTF_8); + switch (materials.algorithmSuite()) { + case ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY: + case ALG_AES_256_CTR_HKDF_SHA512_COMMIT_KEY: + expectedDataCipherAlg = AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.idAsString().getBytes(StandardCharsets.UTF_8); + break; + case ALG_AES_256_GCM_IV12_TAG16_NO_KDF: + case ALG_AES_256_CTR_IV16_TAG16_NO_KDF: + expectedDataCipherAlg = AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName().getBytes(StandardCharsets.UTF_8); + break; + default: + throw new S3EncryptionClientException("The algorithm suite selected for decryption does not match " + + "the content algorithm bound to the RSA-wrapped data key. The object may be altered or corrupted"); } int dataCipherNameLength = expectedDataCipherAlg.length; 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..7c155c627 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,29 @@ // 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.assertThrows; +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 +35,44 @@ 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(); + + assertThrows(S3EncryptionClientException.class, + () -> keyring.onDecrypt(materials, Collections.singletonList(edk))); + } + } diff --git a/src/test/java/software/amazon/encryption/s3/materials/RsaKeyringTest.java b/src/test/java/software/amazon/encryption/s3/materials/RsaKeyringTest.java index 8069b4f02..fcca9f4ce 100644 --- a/src/test/java/software/amazon/encryption/s3/materials/RsaKeyringTest.java +++ b/src/test/java/software/amazon/encryption/s3/materials/RsaKeyringTest.java @@ -2,13 +2,41 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.encryption.s3.materials; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.RetryingTest; +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.awssdk.services.s3.model.PutObjectRequest; import software.amazon.encryption.s3.S3EncryptionClientException; +import software.amazon.encryption.s3.algorithms.AlgorithmSuite; +import software.amazon.encryption.s3.internal.ContentMetadata; +import software.amazon.encryption.s3.internal.ContentMetadataDecodingStrategy; +import software.amazon.encryption.s3.internal.InstructionFileConfig; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; public class RsaKeyringTest { + private static KeyPair RSA_KEY_PAIR; + + @BeforeAll + public static void setUp() throws Exception { + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(2048); + RSA_KEY_PAIR = keyPairGen.generateKeyPair(); + } + @RetryingTest(3) public void buildAesKeyringWithNullSecureRandomFails() { assertThrows(S3EncryptionClientException.class, () -> AesKeyring.builder().secureRandom(null)); @@ -18,4 +46,75 @@ public void buildAesKeyringWithNullSecureRandomFails() { public void buildAesKeyringWithNullDataKeyGeneratorFails() { assertThrows(S3EncryptionClientException.class, () -> AesKeyring.builder().dataKeyGenerator(null)); } + + // RSA-OAEP-SHA1 wraps the data key together with the content cipher ("AES/GCM/NoPadding"). When the + // decryption materials select the CBC suite (as they would if the content-algorithm metadata read CBC), + // the wrapped cipher and the selected suite disagree, so the EDK must be rejected. + @Test + public void rsaOaepDecryptFailsWhenWrappedCekAlgDoesNotMatchSelectedSuite() { + RsaKeyring keyring = RsaKeyring.builder() + .wrappingKeyPair(PartialRsaKeyPair.builder() + .publicKey(RSA_KEY_PAIR.getPublic()) + .privateKey(RSA_KEY_PAIR.getPrivate()) + .build()) + .build(); + + EncryptionMaterials encryptionMaterials = keyring.onEncrypt(EncryptionMaterials.builder() + .s3Request(PutObjectRequest.builder().bucket("bucket").key("key").build()) + .algorithmSuite(AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .plaintextDataKey(new byte[32]) + .build()); + + DecryptionMaterials decryptionMaterials = DecryptionMaterials.builder() + .s3Request(GetObjectRequest.builder().bucket("bucket").key("key").build()) + .algorithmSuite(AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF) + .build(); + + assertThrows(S3EncryptionClientException.class, + () -> keyring.onDecrypt(decryptionMaterials, encryptionMaterials.encryptedDataKeys())); + } + + // Removing x-amz-cek-alg and renaming x-amz-key-v2 to x-amz-key makes the object decode as V1, which + // defaults to the CBC suite while keeping the non-legacy RSA-OAEP-SHA1 wrap-alg. The wrapped cipher is + // still GCM, so the EDK must be rejected for the CBC suite. + @Test + public void rsaOaepDecryptFailsWhenCekAlgHeaderDeletedAndObjectRelabeledV1() { + RsaKeyring keyring = RsaKeyring.builder() + .wrappingKeyPair(PartialRsaKeyPair.builder() + .publicKey(RSA_KEY_PAIR.getPublic()) + .privateKey(RSA_KEY_PAIR.getPrivate()) + .build()) + .build(); + + EncryptionMaterials encryptionMaterials = keyring.onEncrypt(EncryptionMaterials.builder() + .s3Request(PutObjectRequest.builder().bucket("bucket").key("key").build()) + .algorithmSuite(AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .plaintextDataKey(new byte[32]) + .build()); + + Map v1Metadata = new HashMap<>(); + v1Metadata.put("x-amz-key", Base64.getEncoder() + .encodeToString(encryptionMaterials.encryptedDataKeys().get(0).encryptedDatakey())); + v1Metadata.put("x-amz-wrap-alg", "RSA-OAEP-SHA1"); + v1Metadata.put("x-amz-iv", Base64.getEncoder().encodeToString(new byte[16])); + v1Metadata.put("x-amz-matdesc", "{}"); + v1Metadata.put("x-amz-tag-len", "128"); + + GetObjectRequest getObjectRequest = GetObjectRequest.builder().bucket("bucket").key("key").build(); + ContentMetadata contentMetadata = new ContentMetadataDecodingStrategy( + InstructionFileConfig.builder().instructionFileClient(mock(S3Client.class)).build()) + .decode(getObjectRequest, GetObjectResponse.builder().metadata(v1Metadata).build()); + assertEquals(AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF, contentMetadata.algorithmSuite()); + + DecryptionMaterials decryptionMaterials = DecryptionMaterials.builder() + .s3Request(getObjectRequest) + .algorithmSuite(contentMetadata.algorithmSuite()) + .encryptionContext(contentMetadata.encryptionContext()) + .materialsDescription(contentMetadata.materialsDescription()) + .build(); + + assertThrows(S3EncryptionClientException.class, + () -> keyring.onDecrypt(decryptionMaterials, + Collections.singletonList(contentMetadata.encryptedDataKey()))); + } } From 8fc493b17bb78b2ba6ce9bbdcd00befee6c70ee9 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Mon, 26 Oct 2026 22:08:36 +0000 Subject: [PATCH 2/4] fix(kms-keyring): limit content algorithm check to KMS+Context RSA-OAEP data keys record AES/GCM/NoPadding for every non-committing suite, including CBC objects rewrapped by reEncryptInstructionFile, so the wrapped value cannot identify the content algorithm. Revert the RsaKeyring check and its tests; keep the KMS+Context check, where the stored aws:x-amz-cek-alg records the actual content algorithm. --- .../encryption/s3/materials/RsaKeyring.java | 23 ++--- .../s3/materials/RsaKeyringTest.java | 99 ------------------- 2 files changed, 6 insertions(+), 116 deletions(-) diff --git a/src/main/java/software/amazon/encryption/s3/materials/RsaKeyring.java b/src/main/java/software/amazon/encryption/s3/materials/RsaKeyring.java index ba4a67bc3..74cb66a92 100644 --- a/src/main/java/software/amazon/encryption/s3/materials/RsaKeyring.java +++ b/src/main/java/software/amazon/encryption/s3/materials/RsaKeyring.java @@ -158,24 +158,13 @@ private byte[] parsePseudoDataKey(DecryptionMaterials materials, byte[] pseudoDa throw new S3EncryptionClientException("Invalid key length (" + dataKeyLengthBytes + ") in encrypted data key"); } - // The content algorithm sealed inside the pseudo data key must match the algorithm suite selected - // for decryption. RSA-OAEP-SHA1 objects are only ever written with a GCM-family content suite, so - // the committing GCM/CTR pair maps to the committing suite ID (115) and the non-committing GCM/CTR - // pair maps to GCM's cipher name. Any other selected suite (e.g. CBC) does not correspond to how - // the data key was wrapped and is not accepted. byte[] expectedDataCipherAlg; - switch (materials.algorithmSuite()) { - case ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY: - case ALG_AES_256_CTR_HKDF_SHA512_COMMIT_KEY: - expectedDataCipherAlg = AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.idAsString().getBytes(StandardCharsets.UTF_8); - break; - case ALG_AES_256_GCM_IV12_TAG16_NO_KDF: - case ALG_AES_256_CTR_IV16_TAG16_NO_KDF: - expectedDataCipherAlg = AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName().getBytes(StandardCharsets.UTF_8); - break; - default: - throw new S3EncryptionClientException("The algorithm suite selected for decryption does not match " - + "the content algorithm bound to the RSA-wrapped data key. The object may be altered or corrupted"); + // For V3 committed algorithms (both GCM 115 and CTR 116), use GCM's suite ID string (115/0x0073) + if (materials.algorithmSuite().id() == AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.id() || + materials.algorithmSuite().id() == AlgorithmSuite.ALG_AES_256_CTR_HKDF_SHA512_COMMIT_KEY.id()) { + expectedDataCipherAlg = AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.idAsString().getBytes(StandardCharsets.UTF_8); + } else { + expectedDataCipherAlg = AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName().getBytes(StandardCharsets.UTF_8); } int dataCipherNameLength = expectedDataCipherAlg.length; diff --git a/src/test/java/software/amazon/encryption/s3/materials/RsaKeyringTest.java b/src/test/java/software/amazon/encryption/s3/materials/RsaKeyringTest.java index fcca9f4ce..8069b4f02 100644 --- a/src/test/java/software/amazon/encryption/s3/materials/RsaKeyringTest.java +++ b/src/test/java/software/amazon/encryption/s3/materials/RsaKeyringTest.java @@ -2,41 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.encryption.s3.materials; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; import org.junitpioneer.jupiter.RetryingTest; -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.awssdk.services.s3.model.PutObjectRequest; import software.amazon.encryption.s3.S3EncryptionClientException; -import software.amazon.encryption.s3.algorithms.AlgorithmSuite; -import software.amazon.encryption.s3.internal.ContentMetadata; -import software.amazon.encryption.s3.internal.ContentMetadataDecodingStrategy; -import software.amazon.encryption.s3.internal.InstructionFileConfig; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.util.Base64; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.mock; public class RsaKeyringTest { - private static KeyPair RSA_KEY_PAIR; - - @BeforeAll - public static void setUp() throws Exception { - KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); - keyPairGen.initialize(2048); - RSA_KEY_PAIR = keyPairGen.generateKeyPair(); - } - @RetryingTest(3) public void buildAesKeyringWithNullSecureRandomFails() { assertThrows(S3EncryptionClientException.class, () -> AesKeyring.builder().secureRandom(null)); @@ -46,75 +18,4 @@ public void buildAesKeyringWithNullSecureRandomFails() { public void buildAesKeyringWithNullDataKeyGeneratorFails() { assertThrows(S3EncryptionClientException.class, () -> AesKeyring.builder().dataKeyGenerator(null)); } - - // RSA-OAEP-SHA1 wraps the data key together with the content cipher ("AES/GCM/NoPadding"). When the - // decryption materials select the CBC suite (as they would if the content-algorithm metadata read CBC), - // the wrapped cipher and the selected suite disagree, so the EDK must be rejected. - @Test - public void rsaOaepDecryptFailsWhenWrappedCekAlgDoesNotMatchSelectedSuite() { - RsaKeyring keyring = RsaKeyring.builder() - .wrappingKeyPair(PartialRsaKeyPair.builder() - .publicKey(RSA_KEY_PAIR.getPublic()) - .privateKey(RSA_KEY_PAIR.getPrivate()) - .build()) - .build(); - - EncryptionMaterials encryptionMaterials = keyring.onEncrypt(EncryptionMaterials.builder() - .s3Request(PutObjectRequest.builder().bucket("bucket").key("key").build()) - .algorithmSuite(AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) - .plaintextDataKey(new byte[32]) - .build()); - - DecryptionMaterials decryptionMaterials = DecryptionMaterials.builder() - .s3Request(GetObjectRequest.builder().bucket("bucket").key("key").build()) - .algorithmSuite(AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF) - .build(); - - assertThrows(S3EncryptionClientException.class, - () -> keyring.onDecrypt(decryptionMaterials, encryptionMaterials.encryptedDataKeys())); - } - - // Removing x-amz-cek-alg and renaming x-amz-key-v2 to x-amz-key makes the object decode as V1, which - // defaults to the CBC suite while keeping the non-legacy RSA-OAEP-SHA1 wrap-alg. The wrapped cipher is - // still GCM, so the EDK must be rejected for the CBC suite. - @Test - public void rsaOaepDecryptFailsWhenCekAlgHeaderDeletedAndObjectRelabeledV1() { - RsaKeyring keyring = RsaKeyring.builder() - .wrappingKeyPair(PartialRsaKeyPair.builder() - .publicKey(RSA_KEY_PAIR.getPublic()) - .privateKey(RSA_KEY_PAIR.getPrivate()) - .build()) - .build(); - - EncryptionMaterials encryptionMaterials = keyring.onEncrypt(EncryptionMaterials.builder() - .s3Request(PutObjectRequest.builder().bucket("bucket").key("key").build()) - .algorithmSuite(AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) - .plaintextDataKey(new byte[32]) - .build()); - - Map v1Metadata = new HashMap<>(); - v1Metadata.put("x-amz-key", Base64.getEncoder() - .encodeToString(encryptionMaterials.encryptedDataKeys().get(0).encryptedDatakey())); - v1Metadata.put("x-amz-wrap-alg", "RSA-OAEP-SHA1"); - v1Metadata.put("x-amz-iv", Base64.getEncoder().encodeToString(new byte[16])); - v1Metadata.put("x-amz-matdesc", "{}"); - v1Metadata.put("x-amz-tag-len", "128"); - - GetObjectRequest getObjectRequest = GetObjectRequest.builder().bucket("bucket").key("key").build(); - ContentMetadata contentMetadata = new ContentMetadataDecodingStrategy( - InstructionFileConfig.builder().instructionFileClient(mock(S3Client.class)).build()) - .decode(getObjectRequest, GetObjectResponse.builder().metadata(v1Metadata).build()); - assertEquals(AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF, contentMetadata.algorithmSuite()); - - DecryptionMaterials decryptionMaterials = DecryptionMaterials.builder() - .s3Request(getObjectRequest) - .algorithmSuite(contentMetadata.algorithmSuite()) - .encryptionContext(contentMetadata.encryptionContext()) - .materialsDescription(contentMetadata.materialsDescription()) - .build(); - - assertThrows(S3EncryptionClientException.class, - () -> keyring.onDecrypt(decryptionMaterials, - Collections.singletonList(contentMetadata.encryptedDataKey()))); - } } From 4dea03d928ed11a511acc8b59c500f8bffa3131b Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Mon, 2 Nov 2026 19:43:31 +0000 Subject: [PATCH 3/4] fix(kms-keyring): share content algorithm check with KmsDiscoveryKeyring Move the Kms+Context content algorithm check into KmsContentAlgorithm and call it from both KmsKeyring and KmsDiscoveryKeyring. Include the stored content algorithm and the selected suite in the error message. --- .../s3/materials/KmsContentAlgorithm.java | 50 +++++++++++++++++++ .../s3/materials/KmsDiscoveryKeyring.java | 2 + .../encryption/s3/materials/KmsKeyring.java | 29 +---------- .../s3/materials/KmsDiscoveryKeyringTest.java | 46 +++++++++++++++++ .../s3/materials/KmsKeyringTest.java | 43 +++++++++++++++- 5 files changed, 141 insertions(+), 29 deletions(-) create mode 100644 src/main/java/software/amazon/encryption/s3/materials/KmsContentAlgorithm.java 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 9d133a47a..5a6f04008 100644 --- a/src/main/java/software/amazon/encryption/s3/materials/KmsKeyring.java +++ b/src/main/java/software/amazon/encryption/s3/materials/KmsKeyring.java @@ -26,7 +26,6 @@ import software.amazon.awssdk.services.s3.model.S3Request; import software.amazon.encryption.s3.S3EncryptionClient; import software.amazon.encryption.s3.S3EncryptionClientException; -import software.amazon.encryption.s3.algorithms.AlgorithmSuite; import software.amazon.encryption.s3.internal.ApiNameVersion; //= specification/s3-encryption/materials/s3-kms-keyring.md#interface @@ -252,16 +251,7 @@ public byte[] decryptDataKey(DecryptionMaterials materials, byte[] encryptedData throw new S3EncryptionClientException("Provided encryption context does not match information retrieved from S3"); } - // The content encryption algorithm is part of the KMS-authenticated encryption context, while the - // algorithm suite is selected from the (unauthenticated) content metadata. The two must agree; if - // they do not, the content metadata does not correspond to how the object was sealed, so the data - // key is not released for a mismatched suite. - String storedContentAlgorithm = materials.encryptionContext().get(ENCRYPTION_CONTEXT_ALGORITHM_KEY); - if (storedContentAlgorithm != null - && !storedContentAlgorithm.equals(expectedContentAlgorithm(materials.algorithmSuite()))) { - throw new S3EncryptionClientException("The content encryption algorithm in the KMS-authenticated " - + "encryption context does not match the algorithm suite selected for decryption."); - } + 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 @@ -325,23 +315,6 @@ protected Map decryptDataKeyStrategies() { return decryptDataKeyStrategies; } - // The canonical value the content-encryption algorithm (aws:x-amz-cek-alg) holds in the stored encryption - // context for a given selected algorithm suite. The committing GCM/CTR pair share the committing suite ID, - // and the non-committing GCM/CTR pair share GCM's cipher name, because a ranged get selects the CTR variant - // of an object that was sealed under its GCM counterpart. - private static String expectedContentAlgorithm(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(); - } - } - public static class Builder extends S3Keyring.Builder { private KmsClient _kmsClient; private String _wrappingKeyId; 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 7c155c627..9b5efe1f4 100644 --- a/src/test/java/software/amazon/encryption/s3/materials/KmsKeyringTest.java +++ b/src/test/java/software/amazon/encryption/s3/materials/KmsKeyringTest.java @@ -16,7 +16,9 @@ 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; @@ -71,8 +73,47 @@ public void kmsContextDecryptFailsWhenAuthenticatedCekAlgDoesNotMatchSelectedSui .encryptedDataKey(new byte[]{1, 2, 3}) .build(); - assertThrows(S3EncryptionClientException.class, + 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()); } } From 2c87dbab04396375296fcac55add1c50ad1de9e8 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Mon, 2 Nov 2026 19:55:49 +0000 Subject: [PATCH 4/4] test(kms-keyring): add KmsContentAlgorithm unit tests --- .../s3/materials/KmsContentAlgorithmTest.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/test/java/software/amazon/encryption/s3/materials/KmsContentAlgorithmTest.java 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(); + } +}