diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e1ea6dcf0..42c737014 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,6 +49,7 @@ jobs: export AWS_S3EC_TEST_ALT_KMS_KEY_ARN=arn:aws:kms:${{ vars.CI_AWS_REGION }}:${{ secrets.CI_AWS_ACCOUNT_ID }}:key/${{ vars.CI_ALT_KMS_KEY_ID }} export AWS_S3EC_TEST_ALT_ROLE_ARN=arn:aws:iam::${{ secrets.CI_AWS_ACCOUNT_ID }}:role/service-role/${{ vars.CI_ALT_ROLE }} export AWS_S3EC_TEST_BUCKET=${{ vars.CI_S3_BUCKET }} + export AWS_S3EC_TEST_ALT_BUCKET=${{ vars.CI_ALT_S3_BUCKET }} export AWS_S3EC_TEST_KMS_KEY_ID=arn:aws:kms:${{ vars.CI_AWS_REGION }}:${{ secrets.CI_AWS_ACCOUNT_ID }}:key/${{ vars.CI_KMS_KEY_ID }} export AWS_S3EC_TEST_KMS_KEY_ALIAS=arn:aws:kms:${{ vars.CI_AWS_REGION }}:${{ secrets.CI_AWS_ACCOUNT_ID }}:alias/${{ vars.CI_KMS_KEY_ALIAS }} export AWS_REGION=${{ vars.CI_AWS_REGION }} diff --git a/cfn/S3EC-GitHub-CF-Template.yml b/cfn/S3EC-GitHub-CF-Template.yml index 4a9effae2..2051b9aae 100644 --- a/cfn/S3EC-GitHub-CF-Template.yml +++ b/cfn/S3EC-GitHub-CF-Template.yml @@ -64,6 +64,36 @@ Resources: Resource: - !Join [ "", [ !GetAtt S3ECGitHubTestS3Bucket.Arn, '/*'] ] + S3ECGitHubTestS3BucketAlternate: + Type: 'AWS::S3::Bucket' + Properties: + BucketName: s3ec-github-test-bucket-alternate + LifecycleConfiguration: + Rules: + - Id: Expire in 14 days + Status: Enabled + ExpirationInDays: 14 + PublicAccessBlockConfiguration: + BlockPublicAcls: false + BlockPublicPolicy: false + IgnorePublicAcls: false + RestrictPublicBuckets: false + + S3ECGitHubS3BucketPolicyAlternate: + Type: 'AWS::IAM::ManagedPolicy' + Properties: + ManagedPolicyName: S3EC-GitHub-S3-Bucket-Policy-Alternate + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - 's3:PutObject' + - 's3:GetObject' + - 's3:DeleteObject' + Resource: + - !Join [ "", [ !GetAtt S3ECGitHubTestS3BucketAlternate.Arn, '/*'] ] + S3ECGitHubKMSKeyPolicy: Type: 'AWS::IAM::ManagedPolicy' Properties: @@ -149,6 +179,7 @@ Resources: ManagedPolicyArns: - !Ref S3ECGitHubKMSKeyPolicyAlternate - !Ref S3ECGitHubS3BucketPolicy + - !Ref S3ECGitHubS3BucketPolicyAlternate S3ECGitHubAssumeAlternatePolicy: Type: 'AWS::IAM::ManagedPolicy' diff --git a/cfn/release.yml b/cfn/release.yml index ead434dc7..4e135a5f0 100644 --- a/cfn/release.yml +++ b/cfn/release.yml @@ -305,6 +305,36 @@ Resources: Resource: - !Join [ "", [ !GetAtt S3ECReleaseTestS3Bucket.Arn, '/*' ] ] + S3ECReleaseTestS3BucketAlternate: + Type: 'AWS::S3::Bucket' + Properties: + BucketName: !Sub "s3ec-release-test-bucket-alternate" + LifecycleConfiguration: + Rules: + - Id: Expire in 14 days + Status: Enabled + ExpirationInDays: 14 + PublicAccessBlockConfiguration: + BlockPublicAcls: false + BlockPublicPolicy: false + IgnorePublicAcls: false + RestrictPublicBuckets: false + + S3ECReleaseS3BucketPolicyAlternate: + Type: 'AWS::IAM::ManagedPolicy' + Properties: + ManagedPolicyName: S3EC-Release-S3-Bucket-Policy-Alternate + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - 's3:PutObject' + - 's3:GetObject' + - 's3:DeleteObject' + Resource: + - !Join [ "", [ !GetAtt S3ECReleaseTestS3BucketAlternate.Arn, '/*' ] ] + S3ECReleaseTestKMSKeyPolicy: Type: 'AWS::IAM::ManagedPolicy' Properties: @@ -370,3 +400,4 @@ Resources: ManagedPolicyArns: - !Ref S3ECReleaseKMSKeyPolicyAlternate - !Ref S3ECReleaseS3BucketPolicy + - !Ref S3ECReleaseS3BucketPolicyAlternate diff --git a/codebuild/release/release-prod.yml b/codebuild/release/release-prod.yml index 94bbcbd7d..d94af7f30 100644 --- a/codebuild/release/release-prod.yml +++ b/codebuild/release/release-prod.yml @@ -23,6 +23,7 @@ phases: - export AWS_S3EC_TEST_ALT_KMS_KEY_ARN=arn:aws:kms:us-west-2:${ACCOUNT}:key/94f7843c-ec71-4abd-957c-2fb67c991a37 - export AWS_S3EC_TEST_ALT_ROLE_ARN=arn:aws:iam::${ACCOUNT}:role/service-role/S3EC-Release-test-role-alternate - export AWS_S3EC_TEST_BUCKET=s3ec-release-test-bucket + - export AWS_S3EC_TEST_ALT_BUCKET=s3ec-release-test-bucket-alternate - export AWS_S3EC_TEST_KMS_KEY_ID=arn:aws:kms:us-west-2:${ACCOUNT}:key/af4ce40a-05ab-4f7c-b3fa-97bd0c9b7fb1 - export AWS_S3EC_TEST_KMS_KEY_ALIAS=arn:aws:kms:us-west-2:${ACCOUNT}:alias/S3EC-Release-Testing-KMS-Key - export AWS_REGION=us-west-2 diff --git a/codebuild/release/release-staging.yml b/codebuild/release/release-staging.yml index 8450b00b5..65c3a6b09 100644 --- a/codebuild/release/release-staging.yml +++ b/codebuild/release/release-staging.yml @@ -29,6 +29,7 @@ phases: - export AWS_S3EC_TEST_ALT_KMS_KEY_ARN=arn:aws:kms:us-west-2:${ACCOUNT}:key/94f7843c-ec71-4abd-957c-2fb67c991a37 - export AWS_S3EC_TEST_ALT_ROLE_ARN=arn:aws:iam::${ACCOUNT}:role/service-role/S3EC-Release-test-role-alternate - export AWS_S3EC_TEST_BUCKET=s3ec-release-test-bucket + - export AWS_S3EC_TEST_ALT_BUCKET=s3ec-release-test-bucket-alternate - export AWS_S3EC_TEST_KMS_KEY_ID=arn:aws:kms:us-west-2:${ACCOUNT}:key/af4ce40a-05ab-4f7c-b3fa-97bd0c9b7fb1 - export AWS_S3EC_TEST_KMS_KEY_ALIAS=arn:aws:kms:us-west-2:${ACCOUNT}:alias/S3EC-Release-Testing-KMS-Key - export AWS_REGION=us-west-2 diff --git a/codebuild/release/validate-staging.yml b/codebuild/release/validate-staging.yml index ab6803f7e..d390e079a 100644 --- a/codebuild/release/validate-staging.yml +++ b/codebuild/release/validate-staging.yml @@ -26,6 +26,7 @@ phases: - export AWS_S3EC_TEST_ALT_KMS_KEY_ARN=arn:aws:kms:us-west-2:${ACCOUNT}:key/94f7843c-ec71-4abd-957c-2fb67c991a37 - export AWS_S3EC_TEST_ALT_ROLE_ARN=arn:aws:iam::${ACCOUNT}:role/service-role/S3EC-Release-test-role-alternate - export AWS_S3EC_TEST_BUCKET=s3ec-release-test-bucket + - export AWS_S3EC_TEST_ALT_BUCKET=s3ec-release-test-bucket-alternate - export AWS_S3EC_TEST_KMS_KEY_ID=arn:aws:kms:us-west-2:${ACCOUNT}:key/af4ce40a-05ab-4f7c-b3fa-97bd0c9b7fb1 - export AWS_S3EC_TEST_KMS_KEY_ALIAS=arn:aws:kms:us-west-2:${ACCOUNT}:alias/S3EC-Release-Testing-KMS-Key - export AWS_REGION=us-west-2 diff --git a/src/main/java/software/amazon/encryption/s3/internal/ContentMetadataDecodingStrategy.java b/src/main/java/software/amazon/encryption/s3/internal/ContentMetadataDecodingStrategy.java index f180c826b..8e718cb07 100644 --- a/src/main/java/software/amazon/encryption/s3/internal/ContentMetadataDecodingStrategy.java +++ b/src/main/java/software/amazon/encryption/s3/internal/ContentMetadataDecodingStrategy.java @@ -2,10 +2,201 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.encryption.s3.internal; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.protocols.jsoncore.JsonNode; +import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser; +import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.encryption.s3.S3EncryptionClientException; +import software.amazon.encryption.s3.algorithms.AlgorithmSuite; +import software.amazon.encryption.s3.materials.EncryptedDataKey; +import software.amazon.encryption.s3.materials.S3Keyring; -@FunctionalInterface -public interface ContentMetadataDecodingStrategy { - ContentMetadata decodeMetadata(GetObjectRequest request, GetObjectResponse response); +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +import static software.amazon.encryption.s3.S3EncryptionClientUtilities.INSTRUCTION_FILE_SUFFIX; + +public class ContentMetadataDecodingStrategy { + + private static final Base64.Decoder DECODER = Base64.getDecoder(); + + private final S3AsyncClient wrappedAsyncClient_; + + public ContentMetadataDecodingStrategy(S3AsyncClient s3AsyncClient) { + if (s3AsyncClient == null) { + throw new S3EncryptionClientException("ContentMetadataDecodingStrategy requires a non-null async client."); + } + wrappedAsyncClient_ = s3AsyncClient; + } + + private ContentMetadata readFromMap(Map metadata, GetObjectResponse response) { + // Get algorithm suite + final String contentEncryptionAlgorithm = metadata.get(MetadataKeyConstants.CONTENT_CIPHER); + AlgorithmSuite algorithmSuite; + String contentRange = response.contentRange(); + if (contentEncryptionAlgorithm == null + || contentEncryptionAlgorithm.equals(AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF.cipherName())) { + algorithmSuite = AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF; + } else if (contentEncryptionAlgorithm.equals(AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName())) { + // If contentRange is provided, this is a ranged get. + // ranged gets require legacy unauthenticated modes. + // Change AES-GCM to AES-CTR to disable authentication when reading this message. + algorithmSuite = (contentRange == null) + ? AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF + : AlgorithmSuite.ALG_AES_256_CTR_IV16_TAG16_NO_KDF; + } else { + throw new S3EncryptionClientException( + "Unknown content encryption algorithm: " + contentEncryptionAlgorithm); + } + + // Do algorithm suite dependent decoding + byte[] edkCiphertext; + + // Currently, this is not stored within the metadata, + // signal to keyring(s) intended for S3EC + final String keyProviderId = S3Keyring.KEY_PROVIDER_ID; + String keyProviderInfo; + switch (algorithmSuite) { + case ALG_AES_256_CBC_IV16_NO_KDF: + // Extract encrypted data key ciphertext + if (metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V1)) { + edkCiphertext = DECODER.decode(metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V1)); + } else if (metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2)) { + // when using v1 to encrypt in its default mode, it may use the v2 EDK key + // despite also using CBC as the content encryption algorithm, presumably due + // to how the v2 changes were backported to v1 + edkCiphertext = DECODER.decode(metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2)); + } else { + // this shouldn't happen under normal circumstances- only if out-of-band modification + // to the metadata is performed. it is most likely that the data is unrecoverable in this case + throw new S3EncryptionClientException("Malformed object metadata! Could not find the encrypted data key."); + } + + if (!metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_ALGORITHM)) { + /* + For legacy v1 EncryptionOnly objects, + there is no EDK algorithm given, it is either plain AES or RSA + In v3, we infer AES vs. RSA based on the length of the ciphertext. + + In v1, whichever key material is provided in its EncryptionMaterials + is used to decrypt the EDK. + + In v3, this is not possible as the keyring code is factored such that + the keyProviderInfo is known before the keyring is known. + Ciphertext size is expected to be reliable as no AES data key should + exceed 256 bits (32 bytes) + 16 padding bytes. + + In the unlikely event that this assumption is false, the fix would be + to refactor the keyring to always use the material given instead of + inferring it this way. + */ + if (edkCiphertext.length > 48) { + keyProviderInfo = "RSA"; + } else { + keyProviderInfo = "AES"; + } + } else { + keyProviderInfo = metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_ALGORITHM); + } + break; + case ALG_AES_256_GCM_IV12_TAG16_NO_KDF: + case ALG_AES_256_CTR_IV16_TAG16_NO_KDF: + // Check tag length + final int tagLength = Integer.parseInt(metadata.get(MetadataKeyConstants.CONTENT_CIPHER_TAG_LENGTH)); + if (tagLength != algorithmSuite.cipherTagLengthBits()) { + throw new S3EncryptionClientException("Expected tag length (bits) of: " + + algorithmSuite.cipherTagLengthBits() + + ", got: " + tagLength); + } + + // Extract encrypted data key ciphertext and provider id + edkCiphertext = DECODER.decode(metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2)); + keyProviderInfo = metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_ALGORITHM); + + break; + default: + throw new S3EncryptionClientException( + "Unknown content encryption algorithm: " + algorithmSuite.id()); + } + + // Build encrypted data key + EncryptedDataKey edk = EncryptedDataKey.builder() + .encryptedDataKey(edkCiphertext) + .keyProviderId(keyProviderId) + .keyProviderInfo(keyProviderInfo.getBytes(StandardCharsets.UTF_8)) + .build(); + + // Get encrypted data key encryption context + final Map encryptionContext = new HashMap<>(); + final String jsonEncryptionContext = metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_CONTEXT); + try { + JsonNodeParser parser = JsonNodeParser.create(); + JsonNode objectNode = parser.parse(jsonEncryptionContext); + + for (Map.Entry entry : objectNode.asObject().entrySet()) { + encryptionContext.put(entry.getKey(), entry.getValue().asString()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + + // Get content iv + byte[] iv = DECODER.decode(metadata.get(MetadataKeyConstants.CONTENT_IV)); + + return ContentMetadata.builder() + .algorithmSuite(algorithmSuite) + .encryptedDataKey(edk) + .encryptedDataKeyContext(encryptionContext) + .contentIv(iv) + .contentRange(contentRange) + .build(); + } + + public ContentMetadata decode(GetObjectRequest request, GetObjectResponse response) { + Map metadata = response.metadata(); + ContentMetadataDecodingStrategy strategy; + if (metadata != null + && metadata.containsKey(MetadataKeyConstants.CONTENT_IV) + && (metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V1) + || metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2))) { + return decodeFromObjectMetadata(request, response); + } else { + return decodeFromInstructionFile(request, response); + } + } + + private ContentMetadata decodeFromObjectMetadata(GetObjectRequest request, GetObjectResponse response) { + return readFromMap(response.metadata(), response); + } + + private ContentMetadata decodeFromInstructionFile(GetObjectRequest request, GetObjectResponse response) { + GetObjectRequest instructionGetObjectRequest = GetObjectRequest.builder() + .bucket(request.bucket()) + .key(request.key() + INSTRUCTION_FILE_SUFFIX) + .build(); + + ResponseInputStream instruction; + try { + instruction = wrappedAsyncClient_.getObject(instructionGetObjectRequest, AsyncResponseTransformer.toBlockingInputStream()).join(); + } catch (NoSuchKeyException exception) { + // Most likely, the customer is attempting to decrypt an object + // which is not encrypted with the S3 EC. + throw new S3EncryptionClientException("Instruction file not found! Please ensure the object you are" + + " attempting to decrypt has been encrypted using the S3 Encryption Client.", exception); + } + + Map metadata = new HashMap<>(); + JsonNodeParser parser = JsonNodeParser.create(); + JsonNode objectNode = parser.parse(instruction); + for (Map.Entry entry : objectNode.asObject().entrySet()) { + metadata.put(entry.getKey(), entry.getValue().asString()); + } + return readFromMap(metadata, response); + } } diff --git a/src/main/java/software/amazon/encryption/s3/internal/ContentMetadataStrategy.java b/src/main/java/software/amazon/encryption/s3/internal/ContentMetadataStrategy.java deleted file mode 100644 index c698473e7..000000000 --- a/src/main/java/software/amazon/encryption/s3/internal/ContentMetadataStrategy.java +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -package software.amazon.encryption.s3.internal; - -import software.amazon.awssdk.core.ResponseInputStream; -import software.amazon.awssdk.protocols.jsoncore.JsonNode; -import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser; -import software.amazon.awssdk.protocols.jsoncore.JsonWriter; -import software.amazon.awssdk.protocols.jsoncore.JsonWriter.JsonGenerationException; -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.NoSuchKeyException; -import software.amazon.encryption.s3.S3EncryptionClientException; -import software.amazon.encryption.s3.algorithms.AlgorithmSuite; -import software.amazon.encryption.s3.materials.EncryptedDataKey; -import software.amazon.encryption.s3.materials.EncryptionMaterials; -import software.amazon.encryption.s3.materials.S3Keyring; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.HashMap; -import java.util.Map; -import java.util.Map.Entry; - -import static software.amazon.encryption.s3.S3EncryptionClientUtilities.INSTRUCTION_FILE_SUFFIX; - -public abstract class ContentMetadataStrategy implements ContentMetadataEncodingStrategy, ContentMetadataDecodingStrategy { - - private static final Base64.Encoder ENCODER = Base64.getEncoder(); - private static final Base64.Decoder DECODER = Base64.getDecoder(); - - public static final ContentMetadataDecodingStrategy INSTRUCTION_FILE = new ContentMetadataDecodingStrategy() { - - @Override - public ContentMetadata decodeMetadata(GetObjectRequest getObjectRequest, GetObjectResponse response) { - GetObjectRequest instructionGetObjectRequest = GetObjectRequest.builder() - .bucket(getObjectRequest.bucket()) - .key(getObjectRequest.key() + INSTRUCTION_FILE_SUFFIX) - .build(); - - S3Client s3Client = S3Client.create(); - ResponseInputStream instruction; - try { - instruction = s3Client.getObject(instructionGetObjectRequest); - } catch (NoSuchKeyException exception) { - // Most likely, the customer is attempting to decrypt an object - // which is not encrypted with the S3 EC. - throw new S3EncryptionClientException("Instruction file not found! Please ensure the object you are" + - " attempting to decrypt has been encrypted using the S3 Encryption Client.", exception); - } - - Map metadata = new HashMap<>(); - JsonNodeParser parser = JsonNodeParser.create(); - JsonNode objectNode = parser.parse(instruction); - for (Map.Entry entry : objectNode.asObject().entrySet()) { - metadata.put(entry.getKey(), entry.getValue().asString()); - } - return ContentMetadataStrategy.readFromMap(metadata, response); - } - }; - - public static final ContentMetadataStrategy OBJECT_METADATA = new ContentMetadataStrategy() { - - @Override - public Map encodeMetadata(EncryptionMaterials materials, byte[] iv, - Map metadata) { - EncryptedDataKey edk = materials.encryptedDataKeys().get(0); - metadata.put(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2, ENCODER.encodeToString(edk.encryptedDatakey())); - metadata.put(MetadataKeyConstants.CONTENT_IV, ENCODER.encodeToString(iv)); - metadata.put(MetadataKeyConstants.CONTENT_CIPHER, materials.algorithmSuite().cipherName()); - metadata.put(MetadataKeyConstants.CONTENT_CIPHER_TAG_LENGTH, Integer.toString(materials.algorithmSuite().cipherTagLengthBits())); - metadata.put(MetadataKeyConstants.ENCRYPTED_DATA_KEY_ALGORITHM, new String(edk.keyProviderInfo(), StandardCharsets.UTF_8)); - - try (JsonWriter jsonWriter = JsonWriter.create()) { - jsonWriter.writeStartObject(); - for (Entry entry : materials.encryptionContext().entrySet()) { - jsonWriter.writeFieldName(entry.getKey()).writeValue(entry.getValue()); - } - jsonWriter.writeEndObject(); - - String jsonEncryptionContext = new String(jsonWriter.getBytes(), StandardCharsets.UTF_8); - metadata.put(MetadataKeyConstants.ENCRYPTED_DATA_KEY_CONTEXT, jsonEncryptionContext); - } catch (JsonGenerationException e) { - throw new S3EncryptionClientException("Cannot serialize encryption context to JSON.", e); - } - return metadata; - } - - @Override - public ContentMetadata decodeMetadata(GetObjectRequest request, GetObjectResponse response) { - return ContentMetadataStrategy.readFromMap(response.metadata(), response); - } - }; - - private static ContentMetadata readFromMap(Map metadata, GetObjectResponse response) { - // Get algorithm suite - final String contentEncryptionAlgorithm = metadata.get(MetadataKeyConstants.CONTENT_CIPHER); - AlgorithmSuite algorithmSuite; - String contentRange = response.contentRange(); - if (contentEncryptionAlgorithm == null - || contentEncryptionAlgorithm.equals(AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF.cipherName())) { - algorithmSuite = AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF; - } else if (contentEncryptionAlgorithm.equals(AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF.cipherName())) { - // If contentRange is provided, this is a ranged get. - // ranged gets require legacy unauthenticated modes. - // Change AES-GCM to AES-CTR to disable authentication when reading this message. - algorithmSuite = (contentRange == null) - ? AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF - : AlgorithmSuite.ALG_AES_256_CTR_IV16_TAG16_NO_KDF; - } else { - throw new S3EncryptionClientException( - "Unknown content encryption algorithm: " + contentEncryptionAlgorithm); - } - - // Do algorithm suite dependent decoding - byte[] edkCiphertext; - - // Currently, this is not stored within the metadata, - // signal to keyring(s) intended for S3EC - final String keyProviderId = S3Keyring.KEY_PROVIDER_ID; - String keyProviderInfo; - switch (algorithmSuite) { - case ALG_AES_256_CBC_IV16_NO_KDF: - // Extract encrypted data key ciphertext - if (metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V1)) { - edkCiphertext = DECODER.decode(metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V1)); - } else if (metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2)) { - // when using v1 to encrypt in its default mode, it may use the v2 EDK key - // despite also using CBC as the content encryption algorithm, presumably due - // to how the v2 changes were backported to v1 - edkCiphertext = DECODER.decode(metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2)); - } else { - // this shouldn't happen under normal circumstances- only if out-of-band modification - // to the metadata is performed. it is most likely that the data is unrecoverable in this case - throw new S3EncryptionClientException("Malformed object metadata! Could not find the encrypted data key."); - } - - if (!metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_ALGORITHM)) { - /* - For legacy v1 EncryptionOnly objects, - there is no EDK algorithm given, it is either plain AES or RSA - In v3, we infer AES vs. RSA based on the length of the ciphertext. - - In v1, whichever key material is provided in its EncryptionMaterials - is used to decrypt the EDK. - - In v3, this is not possible as the keyring code is factored such that - the keyProviderInfo is known before the keyring is known. - Ciphertext size is expected to be reliable as no AES data key should - exceed 256 bits (32 bytes) + 16 padding bytes. - - In the unlikely event that this assumption is false, the fix would be - to refactor the keyring to always use the material given instead of - inferring it this way. - */ - if (edkCiphertext.length > 48) { - keyProviderInfo = "RSA"; - } else { - keyProviderInfo = "AES"; - } - } else { - keyProviderInfo = metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_ALGORITHM); - } - break; - case ALG_AES_256_GCM_IV12_TAG16_NO_KDF: - case ALG_AES_256_CTR_IV16_TAG16_NO_KDF: - // Check tag length - final int tagLength = Integer.parseInt(metadata.get(MetadataKeyConstants.CONTENT_CIPHER_TAG_LENGTH)); - if (tagLength != algorithmSuite.cipherTagLengthBits()) { - throw new S3EncryptionClientException("Expected tag length (bits) of: " - + algorithmSuite.cipherTagLengthBits() - + ", got: " + tagLength); - } - - // Extract encrypted data key ciphertext and provider id - edkCiphertext = DECODER.decode(metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2)); - keyProviderInfo = metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_ALGORITHM); - - break; - default: - throw new S3EncryptionClientException( - "Unknown content encryption algorithm: " + algorithmSuite.id()); - } - - // Build encrypted data key - EncryptedDataKey edk = EncryptedDataKey.builder() - .encryptedDataKey(edkCiphertext) - .keyProviderId(keyProviderId) - .keyProviderInfo(keyProviderInfo.getBytes(StandardCharsets.UTF_8)) - .build(); - - // Get encrypted data key encryption context - final Map encryptionContext = new HashMap<>(); - final String jsonEncryptionContext = metadata.get(MetadataKeyConstants.ENCRYPTED_DATA_KEY_CONTEXT); - try { - JsonNodeParser parser = JsonNodeParser.create(); - JsonNode objectNode = parser.parse(jsonEncryptionContext); - - for (Map.Entry entry : objectNode.asObject().entrySet()) { - encryptionContext.put(entry.getKey(), entry.getValue().asString()); - } - } catch (Exception e) { - throw new RuntimeException(e); - } - - // Get content iv - byte[] iv = DECODER.decode(metadata.get(MetadataKeyConstants.CONTENT_IV)); - - return ContentMetadata.builder() - .algorithmSuite(algorithmSuite) - .encryptedDataKey(edk) - .encryptedDataKeyContext(encryptionContext) - .contentIv(iv) - .contentRange(contentRange) - .build(); - } - - public static ContentMetadata decode(GetObjectRequest request, GetObjectResponse response) { - Map metadata = response.metadata(); - ContentMetadataDecodingStrategy strategy; - if (metadata != null - && metadata.containsKey(MetadataKeyConstants.CONTENT_IV) - && (metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V1) - || metadata.containsKey(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2))) { - strategy = OBJECT_METADATA; - } else { - strategy = INSTRUCTION_FILE; - } - - return strategy.decodeMetadata(request, response); - } -} diff --git a/src/main/java/software/amazon/encryption/s3/internal/GetEncryptedObjectPipeline.java b/src/main/java/software/amazon/encryption/s3/internal/GetEncryptedObjectPipeline.java index bc08d8004..88f97060f 100644 --- a/src/main/java/software/amazon/encryption/s3/internal/GetEncryptedObjectPipeline.java +++ b/src/main/java/software/amazon/encryption/s3/internal/GetEncryptedObjectPipeline.java @@ -99,6 +99,7 @@ private class DecryptingResponseTransformer implements AsyncResponseTransform ContentMetadata contentMetadata; GetObjectResponse getObjectResponse; DecryptionMaterials materials; + ContentMetadataDecodingStrategy contentMetadataStrategy = new ContentMetadataDecodingStrategy(_s3AsyncClient); CompletableFuture resultFuture; @@ -117,7 +118,7 @@ public CompletableFuture prepare() { @Override public void onResponse(GetObjectResponse response) { getObjectResponse = response; - contentMetadata = ContentMetadataStrategy.decode(getObjectRequest, response); + contentMetadata = contentMetadataStrategy.decode(getObjectRequest, response); materials = prepareMaterialsFromRequest(getObjectRequest, response, contentMetadata); wrappedAsyncResponseTransformer.onResponse(response); } diff --git a/src/main/java/software/amazon/encryption/s3/internal/MultipartUploadObjectPipeline.java b/src/main/java/software/amazon/encryption/s3/internal/MultipartUploadObjectPipeline.java index 9d5394960..b24d6d499 100644 --- a/src/main/java/software/amazon/encryption/s3/internal/MultipartUploadObjectPipeline.java +++ b/src/main/java/software/amazon/encryption/s3/internal/MultipartUploadObjectPipeline.java @@ -217,7 +217,7 @@ public void putLocalObject(RequestBody requestBody, String uploadId, OutputStrea public static class Builder { private final Map _multipartUploadMaterials = Collections.synchronizedMap(new HashMap<>()); - private final ContentMetadataEncodingStrategy _contentMetadataEncodingStrategy = ContentMetadataStrategy.OBJECT_METADATA; + private final ContentMetadataEncodingStrategy _contentMetadataEncodingStrategy = new ObjectMetadataEncodingStrategy(); private S3AsyncClient _s3AsyncClient; private CryptographicMaterialsManager _cryptoMaterialsManager; private SecureRandom _secureRandom; diff --git a/src/main/java/software/amazon/encryption/s3/internal/ObjectMetadataEncodingStrategy.java b/src/main/java/software/amazon/encryption/s3/internal/ObjectMetadataEncodingStrategy.java new file mode 100644 index 000000000..c0f0d5a0c --- /dev/null +++ b/src/main/java/software/amazon/encryption/s3/internal/ObjectMetadataEncodingStrategy.java @@ -0,0 +1,41 @@ +package software.amazon.encryption.s3.internal; + +import software.amazon.awssdk.protocols.jsoncore.JsonWriter; +import software.amazon.encryption.s3.S3EncryptionClientException; +import software.amazon.encryption.s3.materials.EncryptedDataKey; +import software.amazon.encryption.s3.materials.EncryptionMaterials; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +public class ObjectMetadataEncodingStrategy implements ContentMetadataEncodingStrategy { + + private static final Base64.Encoder ENCODER = Base64.getEncoder(); + + @Override + public Map encodeMetadata(EncryptionMaterials materials, byte[] iv, + Map metadata) { + EncryptedDataKey edk = materials.encryptedDataKeys().get(0); + metadata.put(MetadataKeyConstants.ENCRYPTED_DATA_KEY_V2, ENCODER.encodeToString(edk.encryptedDatakey())); + metadata.put(MetadataKeyConstants.CONTENT_IV, ENCODER.encodeToString(iv)); + metadata.put(MetadataKeyConstants.CONTENT_CIPHER, materials.algorithmSuite().cipherName()); + metadata.put(MetadataKeyConstants.CONTENT_CIPHER_TAG_LENGTH, Integer.toString(materials.algorithmSuite().cipherTagLengthBits())); + metadata.put(MetadataKeyConstants.ENCRYPTED_DATA_KEY_ALGORITHM, new String(edk.keyProviderInfo(), StandardCharsets.UTF_8)); + + try (JsonWriter jsonWriter = JsonWriter.create()) { + jsonWriter.writeStartObject(); + for (Map.Entry entry : materials.encryptionContext().entrySet()) { + jsonWriter.writeFieldName(entry.getKey()).writeValue(entry.getValue()); + } + jsonWriter.writeEndObject(); + + String jsonEncryptionContext = new String(jsonWriter.getBytes(), StandardCharsets.UTF_8); + metadata.put(MetadataKeyConstants.ENCRYPTED_DATA_KEY_CONTEXT, jsonEncryptionContext); + } catch (JsonWriter.JsonGenerationException e) { + throw new S3EncryptionClientException("Cannot serialize encryption context to JSON.", e); + } + return metadata; + } + +} diff --git a/src/main/java/software/amazon/encryption/s3/internal/PutEncryptedObjectPipeline.java b/src/main/java/software/amazon/encryption/s3/internal/PutEncryptedObjectPipeline.java index e85fa8e3f..ba7bbbb3b 100644 --- a/src/main/java/software/amazon/encryption/s3/internal/PutEncryptedObjectPipeline.java +++ b/src/main/java/software/amazon/encryption/s3/internal/PutEncryptedObjectPipeline.java @@ -85,8 +85,7 @@ public static class Builder { private CryptographicMaterialsManager _cryptoMaterialsManager; private SecureRandom _secureRandom; private AsyncContentEncryptionStrategy _asyncContentEncryptionStrategy; - private final ContentMetadataEncodingStrategy _contentMetadataEncodingStrategy = ContentMetadataStrategy.OBJECT_METADATA; - + private final ContentMetadataEncodingStrategy _contentMetadataEncodingStrategy = new ObjectMetadataEncodingStrategy(); private Builder() { } diff --git a/src/test/java/software/amazon/encryption/s3/S3EncryptionClientTest.java b/src/test/java/software/amazon/encryption/s3/S3EncryptionClientTest.java index 976c002fa..06ab88ff8 100644 --- a/src/test/java/software/amazon/encryption/s3/S3EncryptionClientTest.java +++ b/src/test/java/software/amazon/encryption/s3/S3EncryptionClientTest.java @@ -2,6 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 package software.amazon.encryption.s3; +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicSessionCredentials; import com.amazonaws.services.s3.AmazonS3EncryptionClientV2; import com.amazonaws.services.s3.AmazonS3EncryptionV2; import com.amazonaws.services.s3.model.CryptoConfigurationV2; @@ -9,11 +13,13 @@ import com.amazonaws.services.s3.model.CryptoStorageMode; import com.amazonaws.services.s3.model.EncryptionMaterials; import com.amazonaws.services.s3.model.EncryptionMaterialsProvider; +import com.amazonaws.services.s3.model.KMSEncryptionMaterials; import com.amazonaws.services.s3.model.StaticEncryptionMaterialsProvider; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.sync.RequestBody; @@ -60,6 +66,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.withSettings; import static software.amazon.encryption.s3.S3EncryptionClient.withAdditionalConfiguration; +import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.ALTERNATE_BUCKET; import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.ALTERNATE_KMS_KEY; import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.BUCKET; import static software.amazon.encryption.s3.utils.S3EncryptionClientTestResources.KMS_KEY_ALIAS; @@ -872,6 +879,97 @@ public void s3EncryptionClientMixedCredentials() { kmsClient.close(); } + @Test + public void s3EncryptionClientMixedCredentialsInstructionFile() { + final String objectKey = appendTestSuffix("wrapped-s3-client-with-mixed-credentials-instruction-file"); + final String input = "SimpleTestOfV3EncryptionClient"; + + // use alternate creds for KMS + AwsCredentialsProvider creds = new S3EncryptionClientTestResources.AlternateRoleCredentialsProvider(); + S3Client s3Client = S3EncryptionClient.builder() + .credentialsProvider(creds) + .kmsKeyId(ALTERNATE_KMS_KEY) + .build(); + + // use alternate creds for S3 + EncryptionMaterialsProvider materialsProvider = + new StaticEncryptionMaterialsProvider(new KMSEncryptionMaterials(ALTERNATE_KMS_KEY)); + CryptoConfigurationV2 cryptoConfig = + new CryptoConfigurationV2(CryptoMode.StrictAuthenticatedEncryption) + .withStorageMode(CryptoStorageMode.InstructionFile); + AwsSessionCredentials sdkV2Creds = (AwsSessionCredentials) creds.resolveCredentials(); + AWSCredentials sdkV1Creds = new BasicSessionCredentials(sdkV2Creds.accessKeyId(), sdkV2Creds.secretAccessKey(), sdkV2Creds.sessionToken()); + AWSCredentialsProvider sdkV1Provider = new AWSStaticCredentialsProvider(sdkV1Creds); + + AmazonS3EncryptionV2 v2Client = AmazonS3EncryptionClientV2.encryptionBuilder() + .withCredentials(sdkV1Provider) + .withCryptoConfiguration(cryptoConfig) + .withEncryptionMaterialsProvider(materialsProvider) + .build(); + + v2Client.putObject(ALTERNATE_BUCKET, objectKey, input); + + ResponseBytes objectResponse = s3Client.getObjectAsBytes(builder -> builder + .bucket(ALTERNATE_BUCKET) + .key(objectKey) + .build()); + String output = objectResponse.asUtf8String(); + assertEquals(input, output); + + // Cleanup + deleteObject(ALTERNATE_BUCKET, objectKey, s3Client); + s3Client.close(); + } + + @Test + public void s3EncryptionClientMixedCredentialsInstructionFileFails() { + final String objectKey = appendTestSuffix("wrapped-s3-client-with-mixed-credentials-instruction-file-fails"); + final String input = "SimpleTestOfV3EncryptionClient"; + + // use alternate creds for KMS + AwsCredentialsProvider creds = new S3EncryptionClientTestResources.AlternateRoleCredentialsProvider(); + S3Client s3Client = S3EncryptionClient.builder() + .credentialsProvider(creds) + .kmsKeyId(ALTERNATE_KMS_KEY) + .build(); + + // use alternate creds for S3 + EncryptionMaterialsProvider materialsProvider = + new StaticEncryptionMaterialsProvider(new KMSEncryptionMaterials(ALTERNATE_KMS_KEY)); + CryptoConfigurationV2 cryptoConfig = + new CryptoConfigurationV2(CryptoMode.StrictAuthenticatedEncryption) + .withStorageMode(CryptoStorageMode.InstructionFile); + AwsSessionCredentials sdkV2Creds = (AwsSessionCredentials) creds.resolveCredentials(); + AWSCredentials sdkV1Creds = new BasicSessionCredentials(sdkV2Creds.accessKeyId(), sdkV2Creds.secretAccessKey(), sdkV2Creds.sessionToken()); + AWSCredentialsProvider sdkV1Provider = new AWSStaticCredentialsProvider(sdkV1Creds); + + AmazonS3EncryptionV2 v2Client = AmazonS3EncryptionClientV2.encryptionBuilder() + .withCredentials(sdkV1Provider) + .withCryptoConfiguration(cryptoConfig) + .withEncryptionMaterialsProvider(materialsProvider) + .build(); + + v2Client.putObject(ALTERNATE_BUCKET, objectKey, input); + + // Default creds should fail + S3Client s3ClientDefault = S3EncryptionClient.builder() + .kmsKeyId(ALTERNATE_KMS_KEY) + .build(); + try { + s3ClientDefault.getObjectAsBytes(builder -> builder + .bucket(ALTERNATE_BUCKET) + .key(objectKey) + .build()); + fail("expected exception"); + } catch (S3EncryptionClientException e) { + // expected + } + + // Cleanup + deleteObject(ALTERNATE_BUCKET, objectKey, s3Client); + s3Client.close(); + } + /** * A simple, reusable round-trip (encryption + decryption) using a given * S3Client. Useful for testing client configuration. diff --git a/src/test/java/software/amazon/encryption/s3/internal/ContentMetadataStrategyTest.java b/src/test/java/software/amazon/encryption/s3/internal/ContentMetadataStrategyTest.java index b69ce00c9..b598516b7 100644 --- a/src/test/java/software/amazon/encryption/s3/internal/ContentMetadataStrategyTest.java +++ b/src/test/java/software/amazon/encryption/s3/internal/ContentMetadataStrategyTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import software.amazon.awssdk.services.s3.S3AsyncClient; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectResponse; @@ -52,7 +53,11 @@ public void decodeWithObjectMetadata() { .contentIv(bytes) .build(); - ContentMetadata contentMetadata = ContentMetadataStrategy.decode(getObjectRequest, getObjectResponse); + // the client won't be used, + // but it needs to not be null. + // just create a default one + S3AsyncClient s3AsyncClient = S3AsyncClient.create(); + ContentMetadata contentMetadata = new ContentMetadataDecodingStrategy(s3AsyncClient).decode(getObjectRequest, getObjectResponse); assertEquals(expectedContentMetadata.algorithmSuite(), contentMetadata.algorithmSuite()); String actualContentIv = Arrays.toString(contentMetadata.contentIv()); String expectedContentIv = Arrays.toString(expectedContentMetadata.contentIv()); diff --git a/src/test/java/software/amazon/encryption/s3/utils/S3EncryptionClientTestResources.java b/src/test/java/software/amazon/encryption/s3/utils/S3EncryptionClientTestResources.java index 96099b4a9..4c68d6677 100644 --- a/src/test/java/software/amazon/encryption/s3/utils/S3EncryptionClientTestResources.java +++ b/src/test/java/software/amazon/encryption/s3/utils/S3EncryptionClientTestResources.java @@ -34,7 +34,8 @@ public class S3EncryptionClientTestResources { public static final String ALTERNATE_ROLE_ARN = System.getenv("AWS_S3EC_TEST_ALT_ROLE_ARN"); // Alternate KMS key, which only the alternate role has access to public static final String ALTERNATE_KMS_KEY = System.getenv("AWS_S3EC_TEST_ALT_KMS_KEY_ARN"); - + // Alternate S3 Bucket, which only the alternate role has access to + public static final String ALTERNATE_BUCKET = System.getenv("AWS_S3EC_TEST_ALT_BUCKET"); /** * Creds provider for the "alternate" role which is useful for testing cred configuration