From bcc20e4cbcf49bd17ddd53e2b742830c93354abe Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Wed, 8 Apr 2026 11:14:00 -0700 Subject: [PATCH 01/19] chore: add tests around downgrade and EC tampering --- test/integration/test_i_security.py | 316 ++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 test/integration/test_i_security.py diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py new file mode 100644 index 00000000..917c646c --- /dev/null +++ b/test/integration/test_i_security.py @@ -0,0 +1,316 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Security integration tests for S3 Encryption Client. + +These tests verify that the client is resilient against metadata-tampering +attacks, particularly downgrade attacks that attempt to bypass encryption +context validation by modifying the wrapping algorithm metadata. +""" +import json +import os + +import boto3 +import pytest +from datetime import datetime + +from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig +from s3_encryption.exceptions import S3EncryptionClientError +from s3_encryption.materials.kms_keyring import KmsKeyring +from s3_encryption.materials.materials import AlgorithmSuite, CommitmentPolicy + +bucket = os.environ.get("CI_S3_BUCKET", "s3ec-python-github-test-bucket") +region = os.environ.get("CI_AWS_REGION", "us-west-2") +kms_key_id = os.environ.get( + "CI_KMS_KEY_ALIAS", "arn:aws:kms:us-west-2:370957321024:alias/S3EC-Python-Github-KMS-Key" +) + + +def _unique_key(prefix): + return prefix + datetime.now().strftime("%Y-%m-%d-%H:%M:%S-%f") + + +def _make_client(algorithm_suite, commitment_policy, enable_legacy_wrapping=False): + """Create an S3EncryptionClient with the given config.""" + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring( + kms_client, kms_key_id, enable_legacy_wrapping_algorithms=enable_legacy_wrapping + ) + wrapped_client = boto3.client("s3") + config = S3EncryptionClientConfig( + keyring, + encryption_algorithm=algorithm_suite, + commitment_policy=commitment_policy, + ) + return S3EncryptionClient(wrapped_client, config) + + +class TestWrappingAlgorithmDowngradeAttack: + """Tests for the wrapping algorithm downgrade attack (pentest finding). + + Attack scenario: An attacker with S3 write access modifies the object's + x-amz-w metadata from '12' (kms+context) to 'kms'. This attempts to + force the KmsKeyring into the KmsV1 decryption path, which does not + perform client-side encryption context comparison. If successful, a + caller providing a mismatched EncryptionContext on get_object would + still decrypt the object, defeating application-level access control + based on encryption context. + """ + + def test_v3_downgrade_wrap_alg_to_kms_rejected_without_legacy(self): + """Tampering x-amz-w from '12' to 'kms' MUST fail when legacy wrapping is disabled. + + The default KmsKeyring does not enable legacy wrapping algorithms, + so the 'kms' wrapping algorithm value should be rejected outright. + """ + key = _unique_key("sec-downgrade-no-legacy-") + data = b"sensitive data with context" + encryption_context = {"project": "alpha"} + + # 1. Encrypt normally with kms+context (V3 format) + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + s3ec.put_object( + Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context + ) + + # 2. Attacker tampers x-amz-w from '12' to 'kms' via S3 copy + plain_s3 = boto3.client("s3") + head = plain_s3.head_object(Bucket=bucket, Key=key) + original_metadata = head["Metadata"] + assert original_metadata.get("x-amz-w") == "12", ( + f"Expected x-amz-w='12', got {original_metadata.get('x-amz-w')}" + ) + + tampered_metadata = original_metadata.copy() + tampered_metadata["x-amz-w"] = "kms" + + plain_s3.copy_object( + Bucket=bucket, + Key=key, + CopySource={"Bucket": bucket, "Key": key}, + Metadata=tampered_metadata, + MetadataDirective="REPLACE", + ) + + # 3. Decryption with mismatched context MUST fail + with pytest.raises((S3EncryptionClientError, Exception)): + s3ec.get_object( + Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} + ) + + def test_v3_downgrade_wrap_alg_to_kms_rejected_with_legacy(self): + """Tampering x-amz-w from '12' to 'kms' MUST still fail even with legacy enabled. + + Even when enable_legacy_wrapping_algorithms=True, the KmsV1 path + passes the *stored* encryption context to KMS Decrypt. Since the + data key was originally encrypted with the 'alpha' context, KMS + itself will reject the Decrypt call (the ciphertext is bound to + the original context). The mismatched 'beta' context should never + produce a successful decryption. + """ + key = _unique_key("sec-downgrade-legacy-") + data = b"sensitive data with context" + encryption_context = {"project": "alpha"} + + # 1. Encrypt with kms+context (V3) + s3ec_encrypt = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + s3ec_encrypt.put_object( + Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context + ) + + # 2. Attacker tampers x-amz-w from '12' to 'kms' + plain_s3 = boto3.client("s3") + head = plain_s3.head_object(Bucket=bucket, Key=key) + tampered_metadata = head["Metadata"].copy() + tampered_metadata["x-amz-w"] = "kms" + + plain_s3.copy_object( + Bucket=bucket, + Key=key, + CopySource={"Bucket": bucket, "Key": key}, + Metadata=tampered_metadata, + MetadataDirective="REPLACE", + ) + + # 3. Decrypt with legacy enabled but mismatched context MUST fail + s3ec_legacy = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + enable_legacy_wrapping=True, + ) + with pytest.raises((S3EncryptionClientError, Exception)): + s3ec_legacy.get_object( + Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} + ) + + def test_v3_downgrade_wrap_alg_correct_context_still_fails(self): + """Tampering x-amz-w from '12' to 'kms' MUST fail even with the correct context. + + The KmsV1 path uses the *stored* encryption context (from x-amz-t) + for the KMS Decrypt call. But the stored context for kms+context + includes the reserved key 'aws:x-amz-cek-alg'. When the wrapping + algorithm is changed to 'kms', the keyring may not reconstruct the + correct KMS encryption context, causing KMS to reject the call. + This verifies the attack fails regardless of what context the + caller provides. + """ + key = _unique_key("sec-downgrade-correct-ctx-") + data = b"sensitive data with context" + encryption_context = {"project": "alpha"} + + # 1. Encrypt with kms+context (V3) + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + s3ec.put_object( + Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context + ) + + # 2. Attacker tampers x-amz-w + plain_s3 = boto3.client("s3") + head = plain_s3.head_object(Bucket=bucket, Key=key) + tampered_metadata = head["Metadata"].copy() + tampered_metadata["x-amz-w"] = "kms" + + plain_s3.copy_object( + Bucket=bucket, + Key=key, + CopySource={"Bucket": bucket, "Key": key}, + Metadata=tampered_metadata, + MetadataDirective="REPLACE", + ) + + # 3. Even with the CORRECT original context, decryption should fail + # because the wrapping algorithm mismatch corrupts the KMS call + s3ec_legacy = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + enable_legacy_wrapping=True, + ) + with pytest.raises((S3EncryptionClientError, Exception)): + s3ec_legacy.get_object( + Bucket=bucket, Key=key, EncryptionContext=encryption_context + ) + + +class TestV2WrappingAlgorithmDowngradeAttack: + """Same downgrade attack but targeting V2 format objects. + + V2 stores the wrapping algorithm in x-amz-wrap-alg. The attacker + changes it from 'kms+context' to 'kms'. + """ + + def test_v2_downgrade_wrap_alg_to_kms_mismatched_context(self): + """Tampering x-amz-wrap-alg from 'kms+context' to 'kms' with wrong context MUST fail.""" + key = _unique_key("sec-v2-downgrade-") + data = b"sensitive v2 data" + encryption_context = {"project": "alpha"} + + # 1. Encrypt with V2 format (AES_GCM, kms+context wrapping) + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + ) + s3ec.put_object( + Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context + ) + + # 2. Attacker tampers x-amz-wrap-alg from 'kms+context' to 'kms' + plain_s3 = boto3.client("s3") + head = plain_s3.head_object(Bucket=bucket, Key=key) + original_metadata = head["Metadata"] + assert original_metadata.get("x-amz-wrap-alg") == "kms+context", ( + f"Expected x-amz-wrap-alg='kms+context', got {original_metadata.get('x-amz-wrap-alg')}" + ) + + tampered_metadata = original_metadata.copy() + tampered_metadata["x-amz-wrap-alg"] = "kms" + + plain_s3.copy_object( + Bucket=bucket, + Key=key, + CopySource={"Bucket": bucket, "Key": key}, + Metadata=tampered_metadata, + MetadataDirective="REPLACE", + ) + + # 3. Decrypt with legacy enabled + mismatched context MUST fail + s3ec_legacy = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + enable_legacy_wrapping=True, + ) + with pytest.raises((S3EncryptionClientError, Exception)): + s3ec_legacy.get_object( + Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} + ) + + +class TestEncryptionContextBypassAttempts: + """Tests verifying encryption context cannot be bypassed through other vectors.""" + + def test_v3_no_context_on_decrypt_after_context_on_encrypt(self): + """Omitting EncryptionContext on get_object MUST fail if object was encrypted with one.""" + key = _unique_key("sec-no-ctx-decrypt-") + data = b"data requiring context" + encryption_context = {"project": "alpha"} + + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + s3ec.put_object( + Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context + ) + + with pytest.raises(S3EncryptionClientError): + s3ec.get_object(Bucket=bucket, Key=key) + + def test_v3_tamper_stored_context_metadata(self): + """Tampering x-amz-t (stored encryption context) MUST cause KMS Decrypt to fail. + + The KMS ciphertext is bound to the original encryption context. + Modifying x-amz-t changes what the client sends to KMS Decrypt, + causing a mismatch with the ciphertext's bound context. + """ + key = _unique_key("sec-tamper-ctx-") + data = b"data with bound context" + encryption_context = {"project": "alpha"} + + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + s3ec.put_object( + Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context + ) + + # Tamper the stored encryption context in x-amz-t + plain_s3 = boto3.client("s3") + head = plain_s3.head_object(Bucket=bucket, Key=key) + tampered_metadata = head["Metadata"].copy() + + # Replace the stored context with attacker-controlled values + tampered_metadata["x-amz-t"] = json.dumps( + {"project": "beta", "aws:x-amz-cek-alg": "115"} + ) + + plain_s3.copy_object( + Bucket=bucket, + Key=key, + CopySource={"Bucket": bucket, "Key": key}, + Metadata=tampered_metadata, + MetadataDirective="REPLACE", + ) + + # Decryption with the tampered context should fail at KMS + with pytest.raises((S3EncryptionClientError, Exception)): + s3ec.get_object( + Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} + ) From b55c28f5b7ed4aec6b31da7f34ac46e8d289f306 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 10 Apr 2026 14:47:37 -0700 Subject: [PATCH 02/19] update tests --- test/integration/test_i_security.py | 88 ++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index 917c646c..73d52bc0 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -199,15 +199,98 @@ def test_v3_downgrade_wrap_alg_correct_context_still_fails(self): ) + @pytest.mark.xfail( + reason="CONFIRMED VULNERABILITY: V3 wrapping algorithm downgrade bypasses " + "encryption context validation when the attacker also copies x-amz-t " + "into x-amz-m. The KmsV1 path reads stored context from x-amz-m, " + "which now contains the original bound context, so KMS Decrypt succeeds " + "and the caller's mismatched EncryptionContext is ignored.", + strict=True, + ) + def test_v3_downgrade_with_matdesc_injection(self): + """Tampering x-amz-w to 'kms' AND copying x-amz-t into x-amz-m defeats V3 protection. + + The previous V3 tests showed the attack fails because the KmsV1 path + reads from x-amz-m (mat_desc), which is empty for V3 objects. But if + the attacker also copies the original encryption context from x-amz-t + into x-amz-m, KMS Decrypt receives the correct bound context and + succeeds. The caller's mismatched EncryptionContext is silently ignored. + + CURRENT BEHAVIOR: Decryption SUCCEEDS with mismatched context — this is the bug. + EXPECTED BEHAVIOR: Decryption should FAIL. + """ + key = _unique_key("sec-v3-downgrade-matdesc-") + data = b"sensitive data with context" + encryption_context = {"project": "alpha"} + + # 1. Encrypt with kms+context (V3) + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + s3ec.put_object( + Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context + ) + + # 2. Attacker tampers x-amz-w AND copies x-amz-t into x-amz-m + plain_s3 = boto3.client("s3") + head = plain_s3.head_object(Bucket=bucket, Key=key) + tampered_metadata = head["Metadata"].copy() + + # Downgrade wrapping algorithm + tampered_metadata["x-amz-w"] = "kms" + # Copy the original bound context from x-amz-t into x-amz-m + # so the KmsV1 path reads it as mat_desc and passes it to KMS Decrypt + tampered_metadata["x-amz-m"] = tampered_metadata["x-amz-t"] + + plain_s3.copy_object( + Bucket=bucket, + Key=key, + CopySource={"Bucket": bucket, "Key": key}, + Metadata=tampered_metadata, + MetadataDirective="REPLACE", + ) + + # 3. Decrypt with legacy enabled + mismatched context SHOULD fail + # but currently SUCCEEDS because x-amz-m now has the bound context + s3ec_legacy = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + enable_legacy_wrapping=True, + ) + with pytest.raises((S3EncryptionClientError, Exception)): + s3ec_legacy.get_object( + Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} + ) + + class TestV2WrappingAlgorithmDowngradeAttack: """Same downgrade attack but targeting V2 format objects. V2 stores the wrapping algorithm in x-amz-wrap-alg. The attacker changes it from 'kms+context' to 'kms'. + + CONFIRMED VULNERABILITY: For V2 objects, the KmsV1 path passes the + stored encryption context (from x-amz-matdesc) directly to KMS Decrypt. + Since x-amz-matdesc contains the original context used during + GenerateDataKey, KMS happily decrypts the data key. The caller's + mismatched EncryptionContext is silently ignored, defeating + application-level access control. """ + @pytest.mark.xfail( + reason="CONFIRMED VULNERABILITY: V2 wrapping algorithm downgrade bypasses " + "encryption context validation when enable_legacy_wrapping_algorithms=True. " + "The KmsV1 path skips client-side context comparison and KMS Decrypt " + "succeeds because x-amz-matdesc contains the original bound context.", + strict=True, + ) def test_v2_downgrade_wrap_alg_to_kms_mismatched_context(self): - """Tampering x-amz-wrap-alg from 'kms+context' to 'kms' with wrong context MUST fail.""" + """Tampering x-amz-wrap-alg from 'kms+context' to 'kms' with wrong context MUST fail. + + CURRENT BEHAVIOR: Decryption SUCCEEDS with mismatched context — this is the bug. + EXPECTED BEHAVIOR: Decryption should FAIL because the caller's context doesn't match. + """ key = _unique_key("sec-v2-downgrade-") data = b"sensitive v2 data" encryption_context = {"project": "alpha"} @@ -240,7 +323,8 @@ def test_v2_downgrade_wrap_alg_to_kms_mismatched_context(self): MetadataDirective="REPLACE", ) - # 3. Decrypt with legacy enabled + mismatched context MUST fail + # 3. Decrypt with legacy enabled + mismatched context SHOULD fail + # but currently SUCCEEDS — this is the vulnerability s3ec_legacy = _make_client( AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, From 2b16f51396a0540dc8f2ac6d75c34bbf35bc7b61 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 10 Apr 2026 14:48:31 -0700 Subject: [PATCH 03/19] test server tests --- .../s3/WrappingAlgorithmDowngradeTests.java | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java new file mode 100644 index 00000000..03bfe931 --- /dev/null +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java @@ -0,0 +1,228 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.encryption.s3; + +import static org.junit.jupiter.api.Assertions.fail; +import static software.amazon.encryption.s3.TestUtils.*; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.CopyObjectRequest; +import com.amazonaws.services.s3.model.ObjectMetadata; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.opentest4j.TestAbortedException; +import software.amazon.encryption.s3.client.S3ECTestServerClient; +import software.amazon.encryption.s3.model.CommitmentPolicy; +import software.amazon.encryption.s3.model.CreateClientInput; +import software.amazon.encryption.s3.model.CreateClientOutput; +import software.amazon.encryption.s3.model.EncryptionAlgorithm; +import software.amazon.encryption.s3.model.GetObjectInput; +import software.amazon.encryption.s3.model.GetObjectOutput; +import software.amazon.encryption.s3.model.KeyMaterial; +import software.amazon.encryption.s3.model.PutObjectInput; +import software.amazon.encryption.s3.model.S3ECConfig; +import software.amazon.encryption.s3.model.S3EncryptionClientError; + +/** + * Wrapping Algorithm Downgrade Attack Tests + * + * These tests verify that S3 Encryption Client implementations are resilient + * against metadata-tampering attacks where an attacker with S3 write access + * modifies the wrapping algorithm metadata to bypass encryption context validation. + * + * Attack scenario: + * 1. Application encrypts with EncryptionContext {"project": "alpha"} using kms+context + * 2. Attacker modifies wrapping algorithm metadata from kms+context to kms + * 3. Attacker also copies the stored encryption context into the mat_desc field + * 4. User calls get_object with EncryptionContext {"project": "beta"} (mismatched) + * 5. Without protection, decryption succeeds because the KmsV1 path skips + * client-side encryption context comparison + * + * The tests cover both V2 (x-amz-wrap-alg) and V3 (x-amz-w) metadata formats. + */ +@DisplayName("Wrapping Algorithm Downgrade Attack Tests") +public class WrappingAlgorithmDowngradeTests { + + private static final AmazonS3 s3Client = AmazonS3ClientBuilder.defaultClient(); + private static final KeyMaterial kmsKeyArn = KeyMaterial.builder() + .kmsKeyId(TestUtils.KMS_KEY_ARN) + .build(); + + // Encryption context used during encryption + private static final String ENCRYPT_CONTEXT = "[project]:[alpha]"; + // Mismatched encryption context used during decryption (the attacker's goal) + private static final String MISMATCHED_CONTEXT = "[project]:[beta]"; + + @BeforeAll + static void setup() { + TestUtils.validateServersRunning(); + } + + /** + * Helper to tamper S3 object metadata by copying the object with replaced metadata. + */ + private void tamperMetadata(String objectKey, Map newMetadata) { + ObjectMetadata replacementMetadata = new ObjectMetadata(); + replacementMetadata.setUserMetadata(newMetadata); + + CopyObjectRequest copyRequest = new CopyObjectRequest( + TestUtils.BUCKET, objectKey, TestUtils.BUCKET, objectKey) + .withNewObjectMetadata(replacementMetadata); + s3Client.copyObject(copyRequest); + } + + /** + * V3 format: Downgrade x-amz-w from "12" (kms+context) to "kms" AND + * copy x-amz-t into x-amz-m so KmsV1 path gets the correct bound context. + * + * Decryption with a mismatched encryption context MUST fail. + */ + @ParameterizedTest(name = "{0}: V3 wrapping algorithm downgrade with matdesc injection must fail") + @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest") + void v3_downgrade_wrap_alg_with_matdesc_injection_must_fail( + TestUtils.LanguageServerTarget language + ) { + if (ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED.contains(language.getLanguageName()) + || ENCRYPTION_CONTEXT_ON_DECRYPT_UNSUPPORTED.contains(language.getLanguageName())) { + throw new TestAbortedException( + "Encryption context not supported for: " + language.getLanguageName()); + } + + String objectKey = appendTestSuffix("sec-v3-downgrade-" + language.getLanguageName()); + S3ECTestServerClient client = TestUtils.testServerClientFor(language); + + // 1. Create client and encrypt with encryption context + CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(kmsKeyArn) + .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT) + .build()) + .build()); + String encryptClientId = clientOutput.getClientId(); + + client.putObject(PutObjectInput.builder() + .clientID(encryptClientId) + .key(objectKey) + .bucket(TestUtils.BUCKET) + .body(ByteBuffer.wrap(objectKey.getBytes(StandardCharsets.UTF_8))) + .metadata(List.of(ENCRYPT_CONTEXT)) + .build()); + + // 2. Tamper: change x-amz-w from "12" to "kms" and copy x-amz-t into x-amz-m + ObjectMetadata head = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); + Map userMeta = head.getUserMetadata(); + userMeta.put("x-amz-w", "kms"); + // Copy the stored encryption context so KmsV1 path gets the bound context + String storedContext = userMeta.get("x-amz-t"); + if (storedContext != null) { + userMeta.put("x-amz-m", storedContext); + } + tamperMetadata(objectKey, userMeta); + + // 3. Create a client with legacy wrapping enabled and attempt decrypt + // with mismatched context — MUST fail + CreateClientOutput legacyClientOutput = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(kmsKeyArn) + .enableLegacyWrappingAlgorithms(true) + .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT) + .build()) + .build()); + String legacyClientId = legacyClientOutput.getClientId(); + + try { + client.getObject(GetObjectInput.builder() + .clientID(legacyClientId) + .bucket(TestUtils.BUCKET) + .key(objectKey) + .metadata(List.of(MISMATCHED_CONTEXT)) + .build()); + fail("V3 downgrade attack should have been rejected for: " + objectKey + + " (language: " + language.getLanguageName() + ")"); + } catch (S3EncryptionClientError e) { + // Expected — the downgrade attack was detected/rejected + } + } + + /** + * V2 format: Downgrade x-amz-wrap-alg from "kms+context" to "kms". + * + * For V2, x-amz-matdesc already contains the original bound context, + * so the attacker only needs to change the wrapping algorithm. + * Decryption with a mismatched encryption context MUST fail. + */ + @ParameterizedTest(name = "{0}: V2 wrapping algorithm downgrade must fail") + @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest") + void v2_downgrade_wrap_alg_must_fail( + TestUtils.LanguageServerTarget language + ) { + if (ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED.contains(language.getLanguageName()) + || ENCRYPTION_CONTEXT_ON_DECRYPT_UNSUPPORTED.contains(language.getLanguageName())) { + throw new TestAbortedException( + "Encryption context not supported for: " + language.getLanguageName()); + } + + String objectKey = appendTestSuffix("sec-v2-downgrade-" + language.getLanguageName()); + S3ECTestServerClient client = TestUtils.testServerClientFor(language); + + // 1. Create client and encrypt with V2 format + encryption context + CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(kmsKeyArn) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .build()) + .build()); + String encryptClientId = clientOutput.getClientId(); + + client.putObject(PutObjectInput.builder() + .clientID(encryptClientId) + .key(objectKey) + .bucket(TestUtils.BUCKET) + .body(ByteBuffer.wrap(objectKey.getBytes(StandardCharsets.UTF_8))) + .metadata(List.of(ENCRYPT_CONTEXT)) + .build()); + + // 2. Tamper: change x-amz-wrap-alg from "kms+context" to "kms" + ObjectMetadata head = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); + Map userMeta = head.getUserMetadata(); + userMeta.put("x-amz-wrap-alg", "kms"); + tamperMetadata(objectKey, userMeta); + + // 3. Create a client with legacy wrapping enabled and attempt decrypt + // with mismatched context — MUST fail + CreateClientOutput legacyClientOutput = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(kmsKeyArn) + .enableLegacyWrappingAlgorithms(true) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .build()) + .build()); + String legacyClientId = legacyClientOutput.getClientId(); + + try { + client.getObject(GetObjectInput.builder() + .clientID(legacyClientId) + .bucket(TestUtils.BUCKET) + .key(objectKey) + .metadata(List.of(MISMATCHED_CONTEXT)) + .build()); + fail("V2 downgrade attack should have been rejected for: " + objectKey + + " (language: " + language.getLanguageName() + ")"); + } catch (S3EncryptionClientError e) { + // Expected — the downgrade attack was detected/rejected + } + } +} From cb19606a0173b7860c29ff4388e0df1d721811f3 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 10 Apr 2026 15:15:34 -0700 Subject: [PATCH 04/19] debug test-server --- test-server/java-tests/build.gradle.kts | 21 +++++---- .../s3/WrappingAlgorithmDowngradeTests.java | 43 ++++++++++++++++++- test-server/python-v3-server/src/main.py | 27 ++++++++++++ 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/test-server/java-tests/build.gradle.kts b/test-server/java-tests/build.gradle.kts index 2d1cbdeb..e382687a 100644 --- a/test-server/java-tests/build.gradle.kts +++ b/test-server/java-tests/build.gradle.kts @@ -53,6 +53,11 @@ tasks { outputs.upToDateWhen { false } outputs.cacheIf { false } + // TEMPORARY: Only run the downgrade attack tests for faster CI iteration + filter { + includeTestsMatching("software.amazon.encryption.s3.WrappingAlgorithmDowngradeTests") + } + // Enable parallel test execution systemProperty("junit.jupiter.execution.parallel.enabled", "true") systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent") @@ -65,15 +70,15 @@ tasks { // Passing information from Gradle into the tests so that we can filter our servers systemProperty("test.filter.servers", System.getProperty("test.filter.servers")) - // For debugging - // // Enable System.out output - // testLogging { - // events("passed", "skipped", "failed", "standardOut", "standardError") - // showStandardStreams = true - // } - // // Disable AWS SDK v1 deprecation warnings - // systemProperty("aws.java.v1.disableDeprecationAnnouncement", "true") + // Enable System.out output for debugging + testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + + // Disable AWS SDK v1 deprecation warnings + systemProperty("aws.java.v1.disableDeprecationAnnouncement", "true") } } diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java index 03bfe931..f958b70b 100644 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java @@ -122,14 +122,23 @@ void v3_downgrade_wrap_alg_with_matdesc_injection_must_fail( // 2. Tamper: change x-amz-w from "12" to "kms" and copy x-amz-t into x-amz-m ObjectMetadata head = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); Map userMeta = head.getUserMetadata(); + System.out.println("[DEBUG] " + language.getLanguageName() + + " — original metadata for " + objectKey + ": " + userMeta); userMeta.put("x-amz-w", "kms"); // Copy the stored encryption context so KmsV1 path gets the bound context String storedContext = userMeta.get("x-amz-t"); if (storedContext != null) { userMeta.put("x-amz-m", storedContext); } + System.out.println("[DEBUG] " + language.getLanguageName() + + " — tampered metadata for " + objectKey + ": " + userMeta); tamperMetadata(objectKey, userMeta); + // Verify tamper took effect + ObjectMetadata verifyHead = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); + System.out.println("[DEBUG] " + language.getLanguageName() + + " — post-tamper metadata for " + objectKey + ": " + verifyHead.getUserMetadata()); + // 3. Create a client with legacy wrapping enabled and attempt decrypt // with mismatched context — MUST fail CreateClientOutput legacyClientOutput = client.createClient(CreateClientInput.builder() @@ -140,18 +149,32 @@ void v3_downgrade_wrap_alg_with_matdesc_injection_must_fail( .build()) .build()); String legacyClientId = legacyClientOutput.getClientId(); + System.out.println("[DEBUG] " + language.getLanguageName() + + " — decrypt client created (legacy wrapping=true), clientId=" + legacyClientId); try { - client.getObject(GetObjectInput.builder() + GetObjectOutput output = client.getObject(GetObjectInput.builder() .clientID(legacyClientId) .bucket(TestUtils.BUCKET) .key(objectKey) .metadata(List.of(MISMATCHED_CONTEXT)) .build()); + String decryptedBody = new String(output.getBody().array(), StandardCharsets.UTF_8); + System.out.println("[DEBUG] " + language.getLanguageName() + + " — VULNERABILITY: decryption succeeded with mismatched context!" + + " body=" + decryptedBody); fail("V3 downgrade attack should have been rejected for: " + objectKey + " (language: " + language.getLanguageName() + ")"); } catch (S3EncryptionClientError e) { + System.out.println("[DEBUG] " + language.getLanguageName() + + " — correctly rejected with S3EncryptionClientError: " + e.getMessage()); // Expected — the downgrade attack was detected/rejected + } catch (Exception e) { + System.out.println("[DEBUG] " + language.getLanguageName() + + " — rejected with unexpected exception type: " + + e.getClass().getName() + ": " + e.getMessage()); + // The attack was rejected, but via a different error type. + // This is still a pass — the important thing is decryption didn't succeed. } } @@ -197,7 +220,11 @@ void v2_downgrade_wrap_alg_must_fail( // 2. Tamper: change x-amz-wrap-alg from "kms+context" to "kms" ObjectMetadata head = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); Map userMeta = head.getUserMetadata(); + System.out.println("[DEBUG] " + language.getLanguageName() + + " — V2 original metadata for " + objectKey + ": " + userMeta); userMeta.put("x-amz-wrap-alg", "kms"); + System.out.println("[DEBUG] " + language.getLanguageName() + + " — V2 tampered metadata for " + objectKey + ": " + userMeta); tamperMetadata(objectKey, userMeta); // 3. Create a client with legacy wrapping enabled and attempt decrypt @@ -213,16 +240,28 @@ void v2_downgrade_wrap_alg_must_fail( String legacyClientId = legacyClientOutput.getClientId(); try { - client.getObject(GetObjectInput.builder() + GetObjectOutput output = client.getObject(GetObjectInput.builder() .clientID(legacyClientId) .bucket(TestUtils.BUCKET) .key(objectKey) .metadata(List.of(MISMATCHED_CONTEXT)) .build()); + String decryptedBody = new String(output.getBody().array(), StandardCharsets.UTF_8); + System.out.println("[DEBUG] " + language.getLanguageName() + + " — V2 VULNERABILITY: decryption succeeded with mismatched context!" + + " body=" + decryptedBody); fail("V2 downgrade attack should have been rejected for: " + objectKey + " (language: " + language.getLanguageName() + ")"); } catch (S3EncryptionClientError e) { + System.out.println("[DEBUG] " + language.getLanguageName() + + " — V2 correctly rejected with S3EncryptionClientError: " + e.getMessage()); // Expected — the downgrade attack was detected/rejected + } catch (Exception e) { + System.out.println("[DEBUG] " + language.getLanguageName() + + " — V2 rejected with unexpected exception type: " + + e.getClass().getName() + ": " + e.getMessage()); + // The attack was rejected, but via a different error type. + // This is still a pass — the important thing is decryption didn't succeed. } } } diff --git a/test-server/python-v3-server/src/main.py b/test-server/python-v3-server/src/main.py index cee2ab4e..1393c75a 100755 --- a/test-server/python-v3-server/src/main.py +++ b/test-server/python-v3-server/src/main.py @@ -11,8 +11,12 @@ import boto3 import uvicorn import json +import logging import uuid +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("python-v3-server") + app = FastAPI(title="Python Server") # Dictionary to store clients with their UUIDs as keys @@ -106,6 +110,11 @@ async def put_object(bucket: str, key: str, request: Request): metadata = request.headers.get("Content-Metadata", "") enc_ctx = metadata_string_to_map(metadata) + logger.info( + "PUT /object/%s/%s — clientID=%s, raw Content-Metadata=%r, parsed enc_ctx=%s", + bucket, key, client_id, metadata, enc_ctx, + ) + # Make the PutObject request response = client.put_object( **{"Bucket": bucket, "Key": key, "Body": body, "EncryptionContext": enc_ctx} @@ -144,6 +153,11 @@ async def get_object(bucket: str, key: str, request: Request): metadata = request.headers.get("Content-Metadata", "") enc_ctx = metadata_string_to_map(metadata) + logger.info( + "GET /object/%s/%s — clientID=%s, raw Content-Metadata=%r, parsed enc_ctx=%s", + bucket, key, client_id, metadata, enc_ctx, + ) + try: # Use the client to make a GetObject request to S3 response = client.get_object(**{"Bucket": bucket, "Key": key, "EncryptionContext": enc_ctx}) @@ -152,6 +166,11 @@ async def get_object(bucket: str, key: str, request: Request): body = response.get("Body").read() if response.get("Body") else b"" metadata = response.get("Metadata", []) + logger.info( + "GET /object/%s/%s — decryption succeeded, body length=%d", + bucket, key, len(body), + ) + # Convert metadata dictionary to a list of key-value pairs if it's a dict if isinstance(metadata, dict): metadata_list = [f"{key}={value}" for key, value in metadata.items()] @@ -166,8 +185,16 @@ async def get_object(bucket: str, key: str, request: Request): # Return the body as the response payload return Response(content=body, headers=headers) except S3EncryptionClientError as ex: + logger.info( + "GET /object/%s/%s — S3EncryptionClientError: %s", + bucket, key, ex, + ) return create_s3_encryption_client_error(str(ex)) except Exception as e: + logger.info( + "GET /object/%s/%s — unexpected %s: %s", + bucket, key, type(e).__name__, e, + ) return create_generic_server_error(str(e)) From d9aaa03e1b284225fa8714cf927a0587e5b57d07 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 10 Apr 2026 15:57:31 -0700 Subject: [PATCH 05/19] fix --- src/s3_encryption/pipelines.py | 8 ++- test/integration/test_i_security.py | 76 ++++++++--------------------- 2 files changed, 28 insertions(+), 56 deletions(-) diff --git a/src/s3_encryption/pipelines.py b/src/s3_encryption/pipelines.py index 5561047a..edfac7c5 100644 --- a/src/s3_encryption/pipelines.py +++ b/src/s3_encryption/pipelines.py @@ -589,7 +589,13 @@ def _decrypt_v3(self, metadata, encryption_context) -> DecryptionMaterials: # Map V3 compressed wrapping algorithm to canonical key_provider_info raw_wrap_alg = metadata.encrypted_data_key_algorithm_v3 or "12" - wrap_alg = self._V3_WRAP_ALG_MAP.get(raw_wrap_alg, raw_wrap_alg) + wrap_alg = self._V3_WRAP_ALG_MAP.get(raw_wrap_alg) + if wrap_alg is None: + raise S3EncryptionClientError( + f"Unknown V3 wrapping algorithm: '{raw_wrap_alg}'. " + f"Valid values are: {list(self._V3_WRAP_ALG_MAP.keys())}. " + f"The object metadata may have been tampered with." + ) encrypted_data_key = EncryptedDataKey( key_provider_id=b"S3Keyring", diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index 73d52bc0..6e030428 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -6,12 +6,13 @@ attacks, particularly downgrade attacks that attempt to bypass encryption context validation by modifying the wrapping algorithm metadata. """ + import json import os +from datetime import datetime import boto3 import pytest -from datetime import datetime from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig from s3_encryption.exceptions import S3EncryptionClientError @@ -71,17 +72,15 @@ def test_v3_downgrade_wrap_alg_to_kms_rejected_without_legacy(self): AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, ) - s3ec.put_object( - Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context - ) + s3ec.put_object(Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context) # 2. Attacker tampers x-amz-w from '12' to 'kms' via S3 copy plain_s3 = boto3.client("s3") head = plain_s3.head_object(Bucket=bucket, Key=key) original_metadata = head["Metadata"] - assert original_metadata.get("x-amz-w") == "12", ( - f"Expected x-amz-w='12', got {original_metadata.get('x-amz-w')}" - ) + assert ( + original_metadata.get("x-amz-w") == "12" + ), f"Expected x-amz-w='12', got {original_metadata.get('x-amz-w')}" tampered_metadata = original_metadata.copy() tampered_metadata["x-amz-w"] = "kms" @@ -96,9 +95,7 @@ def test_v3_downgrade_wrap_alg_to_kms_rejected_without_legacy(self): # 3. Decryption with mismatched context MUST fail with pytest.raises((S3EncryptionClientError, Exception)): - s3ec.get_object( - Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} - ) + s3ec.get_object(Bucket=bucket, Key=key, EncryptionContext={"project": "beta"}) def test_v3_downgrade_wrap_alg_to_kms_rejected_with_legacy(self): """Tampering x-amz-w from '12' to 'kms' MUST still fail even with legacy enabled. @@ -144,9 +141,7 @@ def test_v3_downgrade_wrap_alg_to_kms_rejected_with_legacy(self): enable_legacy_wrapping=True, ) with pytest.raises((S3EncryptionClientError, Exception)): - s3ec_legacy.get_object( - Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} - ) + s3ec_legacy.get_object(Bucket=bucket, Key=key, EncryptionContext={"project": "beta"}) def test_v3_downgrade_wrap_alg_correct_context_still_fails(self): """Tampering x-amz-w from '12' to 'kms' MUST fail even with the correct context. @@ -168,9 +163,7 @@ def test_v3_downgrade_wrap_alg_correct_context_still_fails(self): AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, ) - s3ec.put_object( - Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context - ) + s3ec.put_object(Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context) # 2. Attacker tampers x-amz-w plain_s3 = boto3.client("s3") @@ -194,19 +187,8 @@ def test_v3_downgrade_wrap_alg_correct_context_still_fails(self): enable_legacy_wrapping=True, ) with pytest.raises((S3EncryptionClientError, Exception)): - s3ec_legacy.get_object( - Bucket=bucket, Key=key, EncryptionContext=encryption_context - ) - + s3ec_legacy.get_object(Bucket=bucket, Key=key, EncryptionContext=encryption_context) - @pytest.mark.xfail( - reason="CONFIRMED VULNERABILITY: V3 wrapping algorithm downgrade bypasses " - "encryption context validation when the attacker also copies x-amz-t " - "into x-amz-m. The KmsV1 path reads stored context from x-amz-m, " - "which now contains the original bound context, so KMS Decrypt succeeds " - "and the caller's mismatched EncryptionContext is ignored.", - strict=True, - ) def test_v3_downgrade_with_matdesc_injection(self): """Tampering x-amz-w to 'kms' AND copying x-amz-t into x-amz-m defeats V3 protection. @@ -228,9 +210,7 @@ def test_v3_downgrade_with_matdesc_injection(self): AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, ) - s3ec.put_object( - Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context - ) + s3ec.put_object(Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context) # 2. Attacker tampers x-amz-w AND copies x-amz-t into x-amz-m plain_s3 = boto3.client("s3") @@ -259,9 +239,7 @@ def test_v3_downgrade_with_matdesc_injection(self): enable_legacy_wrapping=True, ) with pytest.raises((S3EncryptionClientError, Exception)): - s3ec_legacy.get_object( - Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} - ) + s3ec_legacy.get_object(Bucket=bucket, Key=key, EncryptionContext={"project": "beta"}) class TestV2WrappingAlgorithmDowngradeAttack: @@ -300,17 +278,15 @@ def test_v2_downgrade_wrap_alg_to_kms_mismatched_context(self): AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, ) - s3ec.put_object( - Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context - ) + s3ec.put_object(Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context) # 2. Attacker tampers x-amz-wrap-alg from 'kms+context' to 'kms' plain_s3 = boto3.client("s3") head = plain_s3.head_object(Bucket=bucket, Key=key) original_metadata = head["Metadata"] - assert original_metadata.get("x-amz-wrap-alg") == "kms+context", ( - f"Expected x-amz-wrap-alg='kms+context', got {original_metadata.get('x-amz-wrap-alg')}" - ) + assert ( + original_metadata.get("x-amz-wrap-alg") == "kms+context" + ), f"Expected x-amz-wrap-alg='kms+context', got {original_metadata.get('x-amz-wrap-alg')}" tampered_metadata = original_metadata.copy() tampered_metadata["x-amz-wrap-alg"] = "kms" @@ -331,9 +307,7 @@ def test_v2_downgrade_wrap_alg_to_kms_mismatched_context(self): enable_legacy_wrapping=True, ) with pytest.raises((S3EncryptionClientError, Exception)): - s3ec_legacy.get_object( - Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} - ) + s3ec_legacy.get_object(Bucket=bucket, Key=key, EncryptionContext={"project": "beta"}) class TestEncryptionContextBypassAttempts: @@ -349,9 +323,7 @@ def test_v3_no_context_on_decrypt_after_context_on_encrypt(self): AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, ) - s3ec.put_object( - Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context - ) + s3ec.put_object(Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context) with pytest.raises(S3EncryptionClientError): s3ec.get_object(Bucket=bucket, Key=key) @@ -371,9 +343,7 @@ def test_v3_tamper_stored_context_metadata(self): AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, ) - s3ec.put_object( - Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context - ) + s3ec.put_object(Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context) # Tamper the stored encryption context in x-amz-t plain_s3 = boto3.client("s3") @@ -381,9 +351,7 @@ def test_v3_tamper_stored_context_metadata(self): tampered_metadata = head["Metadata"].copy() # Replace the stored context with attacker-controlled values - tampered_metadata["x-amz-t"] = json.dumps( - {"project": "beta", "aws:x-amz-cek-alg": "115"} - ) + tampered_metadata["x-amz-t"] = json.dumps({"project": "beta", "aws:x-amz-cek-alg": "115"}) plain_s3.copy_object( Bucket=bucket, @@ -395,6 +363,4 @@ def test_v3_tamper_stored_context_metadata(self): # Decryption with the tampered context should fail at KMS with pytest.raises((S3EncryptionClientError, Exception)): - s3ec.get_object( - Bucket=bucket, Key=key, EncryptionContext={"project": "beta"} - ) + s3ec.get_object(Bucket=bucket, Key=key, EncryptionContext={"project": "beta"}) From a59ea3bbe7585f720613d9ccab0a2b6b8923128b Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 10 Apr 2026 15:58:15 -0700 Subject: [PATCH 06/19] remove logging, more defense in depth --- src/s3_encryption/pipelines.py | 7 ++- test-server/java-tests/build.gradle.kts | 21 ++++----- .../s3/WrappingAlgorithmDowngradeTests.java | 44 ++----------------- test-server/python-v3-server/src/main.py | 27 ------------ 4 files changed, 18 insertions(+), 81 deletions(-) diff --git a/src/s3_encryption/pipelines.py b/src/s3_encryption/pipelines.py index edfac7c5..b2e5d58f 100644 --- a/src/s3_encryption/pipelines.py +++ b/src/s3_encryption/pipelines.py @@ -612,8 +612,13 @@ def _decrypt_v3(self, metadata, encryption_context) -> DecryptionMaterials: stored_context = {} if wrap_alg == "kms+context": raw_ctx = metadata.encryption_context_v3 - else: + elif wrap_alg in ("AES/GCM", "RSA-OAEP-SHA1"): raw_ctx = metadata.mat_desc_v3 + else: + raise S3EncryptionClientError( + f"Unexpected V3 wrapping algorithm for context selection: '{wrap_alg}'. " + f"The object metadata may have been tampered with." + ) if raw_ctx is not None: if isinstance(raw_ctx, dict): diff --git a/test-server/java-tests/build.gradle.kts b/test-server/java-tests/build.gradle.kts index e382687a..2d1cbdeb 100644 --- a/test-server/java-tests/build.gradle.kts +++ b/test-server/java-tests/build.gradle.kts @@ -53,11 +53,6 @@ tasks { outputs.upToDateWhen { false } outputs.cacheIf { false } - // TEMPORARY: Only run the downgrade attack tests for faster CI iteration - filter { - includeTestsMatching("software.amazon.encryption.s3.WrappingAlgorithmDowngradeTests") - } - // Enable parallel test execution systemProperty("junit.jupiter.execution.parallel.enabled", "true") systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent") @@ -70,15 +65,15 @@ tasks { // Passing information from Gradle into the tests so that we can filter our servers systemProperty("test.filter.servers", System.getProperty("test.filter.servers")) + // For debugging + // // Enable System.out output + // testLogging { + // events("passed", "skipped", "failed", "standardOut", "standardError") + // showStandardStreams = true + // } - // Enable System.out output for debugging - testLogging { - events("passed", "skipped", "failed", "standardOut", "standardError") - showStandardStreams = true - } - - // Disable AWS SDK v1 deprecation warnings - systemProperty("aws.java.v1.disableDeprecationAnnouncement", "true") + // // Disable AWS SDK v1 deprecation warnings + // systemProperty("aws.java.v1.disableDeprecationAnnouncement", "true") } } diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java index f958b70b..211cdfe3 100644 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java @@ -28,7 +28,6 @@ import software.amazon.encryption.s3.model.CreateClientOutput; import software.amazon.encryption.s3.model.EncryptionAlgorithm; import software.amazon.encryption.s3.model.GetObjectInput; -import software.amazon.encryption.s3.model.GetObjectOutput; import software.amazon.encryption.s3.model.KeyMaterial; import software.amazon.encryption.s3.model.PutObjectInput; import software.amazon.encryption.s3.model.S3ECConfig; @@ -122,23 +121,14 @@ void v3_downgrade_wrap_alg_with_matdesc_injection_must_fail( // 2. Tamper: change x-amz-w from "12" to "kms" and copy x-amz-t into x-amz-m ObjectMetadata head = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); Map userMeta = head.getUserMetadata(); - System.out.println("[DEBUG] " + language.getLanguageName() - + " — original metadata for " + objectKey + ": " + userMeta); userMeta.put("x-amz-w", "kms"); // Copy the stored encryption context so KmsV1 path gets the bound context String storedContext = userMeta.get("x-amz-t"); if (storedContext != null) { userMeta.put("x-amz-m", storedContext); } - System.out.println("[DEBUG] " + language.getLanguageName() - + " — tampered metadata for " + objectKey + ": " + userMeta); tamperMetadata(objectKey, userMeta); - // Verify tamper took effect - ObjectMetadata verifyHead = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); - System.out.println("[DEBUG] " + language.getLanguageName() - + " — post-tamper metadata for " + objectKey + ": " + verifyHead.getUserMetadata()); - // 3. Create a client with legacy wrapping enabled and attempt decrypt // with mismatched context — MUST fail CreateClientOutput legacyClientOutput = client.createClient(CreateClientInput.builder() @@ -149,32 +139,20 @@ void v3_downgrade_wrap_alg_with_matdesc_injection_must_fail( .build()) .build()); String legacyClientId = legacyClientOutput.getClientId(); - System.out.println("[DEBUG] " + language.getLanguageName() - + " — decrypt client created (legacy wrapping=true), clientId=" + legacyClientId); try { - GetObjectOutput output = client.getObject(GetObjectInput.builder() + client.getObject(GetObjectInput.builder() .clientID(legacyClientId) .bucket(TestUtils.BUCKET) .key(objectKey) .metadata(List.of(MISMATCHED_CONTEXT)) .build()); - String decryptedBody = new String(output.getBody().array(), StandardCharsets.UTF_8); - System.out.println("[DEBUG] " + language.getLanguageName() - + " — VULNERABILITY: decryption succeeded with mismatched context!" - + " body=" + decryptedBody); fail("V3 downgrade attack should have been rejected for: " + objectKey + " (language: " + language.getLanguageName() + ")"); } catch (S3EncryptionClientError e) { - System.out.println("[DEBUG] " + language.getLanguageName() - + " — correctly rejected with S3EncryptionClientError: " + e.getMessage()); // Expected — the downgrade attack was detected/rejected } catch (Exception e) { - System.out.println("[DEBUG] " + language.getLanguageName() - + " — rejected with unexpected exception type: " - + e.getClass().getName() + ": " + e.getMessage()); - // The attack was rejected, but via a different error type. - // This is still a pass — the important thing is decryption didn't succeed. + // The attack was rejected, but via a different error type — still a pass. } } @@ -220,11 +198,7 @@ void v2_downgrade_wrap_alg_must_fail( // 2. Tamper: change x-amz-wrap-alg from "kms+context" to "kms" ObjectMetadata head = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); Map userMeta = head.getUserMetadata(); - System.out.println("[DEBUG] " + language.getLanguageName() - + " — V2 original metadata for " + objectKey + ": " + userMeta); userMeta.put("x-amz-wrap-alg", "kms"); - System.out.println("[DEBUG] " + language.getLanguageName() - + " — V2 tampered metadata for " + objectKey + ": " + userMeta); tamperMetadata(objectKey, userMeta); // 3. Create a client with legacy wrapping enabled and attempt decrypt @@ -240,28 +214,18 @@ void v2_downgrade_wrap_alg_must_fail( String legacyClientId = legacyClientOutput.getClientId(); try { - GetObjectOutput output = client.getObject(GetObjectInput.builder() + client.getObject(GetObjectInput.builder() .clientID(legacyClientId) .bucket(TestUtils.BUCKET) .key(objectKey) .metadata(List.of(MISMATCHED_CONTEXT)) .build()); - String decryptedBody = new String(output.getBody().array(), StandardCharsets.UTF_8); - System.out.println("[DEBUG] " + language.getLanguageName() - + " — V2 VULNERABILITY: decryption succeeded with mismatched context!" - + " body=" + decryptedBody); fail("V2 downgrade attack should have been rejected for: " + objectKey + " (language: " + language.getLanguageName() + ")"); } catch (S3EncryptionClientError e) { - System.out.println("[DEBUG] " + language.getLanguageName() - + " — V2 correctly rejected with S3EncryptionClientError: " + e.getMessage()); // Expected — the downgrade attack was detected/rejected } catch (Exception e) { - System.out.println("[DEBUG] " + language.getLanguageName() - + " — V2 rejected with unexpected exception type: " - + e.getClass().getName() + ": " + e.getMessage()); - // The attack was rejected, but via a different error type. - // This is still a pass — the important thing is decryption didn't succeed. + // The attack was rejected, but via a different error type — still a pass. } } } diff --git a/test-server/python-v3-server/src/main.py b/test-server/python-v3-server/src/main.py index 1393c75a..cee2ab4e 100755 --- a/test-server/python-v3-server/src/main.py +++ b/test-server/python-v3-server/src/main.py @@ -11,12 +11,8 @@ import boto3 import uvicorn import json -import logging import uuid -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("python-v3-server") - app = FastAPI(title="Python Server") # Dictionary to store clients with their UUIDs as keys @@ -110,11 +106,6 @@ async def put_object(bucket: str, key: str, request: Request): metadata = request.headers.get("Content-Metadata", "") enc_ctx = metadata_string_to_map(metadata) - logger.info( - "PUT /object/%s/%s — clientID=%s, raw Content-Metadata=%r, parsed enc_ctx=%s", - bucket, key, client_id, metadata, enc_ctx, - ) - # Make the PutObject request response = client.put_object( **{"Bucket": bucket, "Key": key, "Body": body, "EncryptionContext": enc_ctx} @@ -153,11 +144,6 @@ async def get_object(bucket: str, key: str, request: Request): metadata = request.headers.get("Content-Metadata", "") enc_ctx = metadata_string_to_map(metadata) - logger.info( - "GET /object/%s/%s — clientID=%s, raw Content-Metadata=%r, parsed enc_ctx=%s", - bucket, key, client_id, metadata, enc_ctx, - ) - try: # Use the client to make a GetObject request to S3 response = client.get_object(**{"Bucket": bucket, "Key": key, "EncryptionContext": enc_ctx}) @@ -166,11 +152,6 @@ async def get_object(bucket: str, key: str, request: Request): body = response.get("Body").read() if response.get("Body") else b"" metadata = response.get("Metadata", []) - logger.info( - "GET /object/%s/%s — decryption succeeded, body length=%d", - bucket, key, len(body), - ) - # Convert metadata dictionary to a list of key-value pairs if it's a dict if isinstance(metadata, dict): metadata_list = [f"{key}={value}" for key, value in metadata.items()] @@ -185,16 +166,8 @@ async def get_object(bucket: str, key: str, request: Request): # Return the body as the response payload return Response(content=body, headers=headers) except S3EncryptionClientError as ex: - logger.info( - "GET /object/%s/%s — S3EncryptionClientError: %s", - bucket, key, ex, - ) return create_s3_encryption_client_error(str(ex)) except Exception as e: - logger.info( - "GET /object/%s/%s — unexpected %s: %s", - bucket, key, type(e).__name__, e, - ) return create_generic_server_error(str(e)) From e7ca1d6a92346610484f1e088d72fbf87471743e Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 10 Apr 2026 16:17:23 -0700 Subject: [PATCH 07/19] adjust test expectations --- .../s3/WrappingAlgorithmDowngradeTests.java | 84 ++++++++++++------- test/integration/test_i_security.py | 68 ++++++--------- 2 files changed, 79 insertions(+), 73 deletions(-) diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java index 211cdfe3..38ccb055 100644 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java @@ -13,6 +13,8 @@ import java.util.List; import java.util.Map; +import java.util.Set; + import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.amazonaws.services.s3.model.CopyObjectRequest; @@ -34,23 +36,21 @@ import software.amazon.encryption.s3.model.S3EncryptionClientError; /** - * Wrapping Algorithm Downgrade Attack Tests + * Wrapping Algorithm Downgrade Tests * - * These tests verify that S3 Encryption Client implementations are resilient - * against metadata-tampering attacks where an attacker with S3 write access - * modifies the wrapping algorithm metadata to bypass encryption context validation. + * These tests verify S3 Encryption Client behavior when the wrapping algorithm + * metadata is modified from kms+context to kms. This simulates a scenario where + * an attacker with S3 write access tampers with object metadata to attempt to + * bypass encryption context validation. * - * Attack scenario: - * 1. Application encrypts with EncryptionContext {"project": "alpha"} using kms+context - * 2. Attacker modifies wrapping algorithm metadata from kms+context to kms - * 3. Attacker also copies the stored encryption context into the mat_desc field - * 4. User calls get_object with EncryptionContext {"project": "beta"} (mismatched) - * 5. Without protection, decryption succeeds because the KmsV1 path skips - * client-side encryption context comparison + * V3 format: All implementations MUST reject the downgrade because "kms" is not + * a valid V3 compressed wrapping algorithm code. * - * The tests cover both V2 (x-amz-wrap-alg) and V3 (x-amz-w) metadata formats. + * V2 format: Implementations that validate encryption context at the pipeline + * layer (Go, C++) reject the downgrade. Implementations that delegate context + * validation to the keyring may not catch the downgrade in V2 format. */ -@DisplayName("Wrapping Algorithm Downgrade Attack Tests") +@DisplayName("Wrapping Algorithm Downgrade Tests") public class WrappingAlgorithmDowngradeTests { private static final AmazonS3 s3Client = AmazonS3ClientBuilder.defaultClient(); @@ -58,6 +58,13 @@ public class WrappingAlgorithmDowngradeTests { .kmsKeyId(TestUtils.KMS_KEY_ARN) .build(); + // Languages that validate encryption context at the pipeline layer, + // making them resilient to V2 wrapping algorithm downgrade. + private static final Set V2_DOWNGRADE_RESILIENT = Set.of( + TestUtils.GO_V4, + TestUtils.CPP_V3 + ); + // Encryption context used during encryption private static final String ENCRYPT_CONTEXT = "[project]:[alpha]"; // Mismatched encryption context used during decryption (the attacker's goal) @@ -82,10 +89,11 @@ private void tamperMetadata(String objectKey, Map newMetadata) { } /** - * V3 format: Downgrade x-amz-w from "12" (kms+context) to "kms" AND - * copy x-amz-t into x-amz-m so KmsV1 path gets the correct bound context. + * V3 format: Changing x-amz-w from "12" (kms+context) to "kms" AND + * copying x-amz-t into x-amz-m MUST be rejected. * - * Decryption with a mismatched encryption context MUST fail. + * "kms" is not a valid V3 compressed wrapping algorithm code, so all + * implementations MUST reject this. */ @ParameterizedTest(name = "{0}: V3 wrapping algorithm downgrade with matdesc injection must fail") @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest") @@ -122,7 +130,7 @@ void v3_downgrade_wrap_alg_with_matdesc_injection_must_fail( ObjectMetadata head = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); Map userMeta = head.getUserMetadata(); userMeta.put("x-amz-w", "kms"); - // Copy the stored encryption context so KmsV1 path gets the bound context + // Copy the stored encryption context into x-amz-m String storedContext = userMeta.get("x-amz-t"); if (storedContext != null) { userMeta.put("x-amz-m", storedContext); @@ -147,25 +155,29 @@ void v3_downgrade_wrap_alg_with_matdesc_injection_must_fail( .key(objectKey) .metadata(List.of(MISMATCHED_CONTEXT)) .build()); - fail("V3 downgrade attack should have been rejected for: " + objectKey + fail("V3 downgrade should have been rejected for: " + objectKey + " (language: " + language.getLanguageName() + ")"); } catch (S3EncryptionClientError e) { - // Expected — the downgrade attack was detected/rejected + // Expected — tampered wrapping algorithm was rejected } catch (Exception e) { - // The attack was rejected, but via a different error type — still a pass. + // Rejected via a different error type — still a pass. } } /** - * V2 format: Downgrade x-amz-wrap-alg from "kms+context" to "kms". + * V2 format: Changing x-amz-wrap-alg from "kms+context" to "kms". * * For V2, x-amz-matdesc already contains the original bound context, - * so the attacker only needs to change the wrapping algorithm. - * Decryption with a mismatched encryption context MUST fail. + * so only the wrapping algorithm needs to change. + * + * Languages that validate encryption context at the pipeline layer (Go, C++) + * reject this regardless of wrapping algorithm. Other languages delegate + * context validation to the keyring, where the KmsV1 path does not + * perform the comparison. */ - @ParameterizedTest(name = "{0}: V2 wrapping algorithm downgrade must fail") + @ParameterizedTest(name = "{0}: V2 wrapping algorithm downgrade") @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest") - void v2_downgrade_wrap_alg_must_fail( + void v2_downgrade_wrap_alg( TestUtils.LanguageServerTarget language ) { if (ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED.contains(language.getLanguageName()) @@ -174,6 +186,8 @@ void v2_downgrade_wrap_alg_must_fail( "Encryption context not supported for: " + language.getLanguageName()); } + boolean expectRejection = V2_DOWNGRADE_RESILIENT.contains(language.getLanguageName()); + String objectKey = appendTestSuffix("sec-v2-downgrade-" + language.getLanguageName()); S3ECTestServerClient client = TestUtils.testServerClientFor(language); @@ -202,7 +216,7 @@ void v2_downgrade_wrap_alg_must_fail( tamperMetadata(objectKey, userMeta); // 3. Create a client with legacy wrapping enabled and attempt decrypt - // with mismatched context — MUST fail + // with mismatched context CreateClientOutput legacyClientOutput = client.createClient(CreateClientInput.builder() .config(S3ECConfig.builder() .keyMaterial(kmsKeyArn) @@ -220,12 +234,18 @@ void v2_downgrade_wrap_alg_must_fail( .key(objectKey) .metadata(List.of(MISMATCHED_CONTEXT)) .build()); - fail("V2 downgrade attack should have been rejected for: " + objectKey - + " (language: " + language.getLanguageName() + ")"); - } catch (S3EncryptionClientError e) { - // Expected — the downgrade attack was detected/rejected - } catch (Exception e) { - // The attack was rejected, but via a different error type — still a pass. + // Decryption succeeded with mismatched context + if (expectRejection) { + fail("V2 downgrade should have been rejected for: " + objectKey + + " (language: " + language.getLanguageName() + ")"); + } + // For non-resilient languages, this is the expected (known) behavior + } catch (S3EncryptionClientError | Exception e) { + if (!expectRejection) { + fail("V2 downgrade was unexpectedly rejected for: " + objectKey + + " (language: " + language.getLanguageName() + "): " + e.getMessage()); + } + // For resilient languages, rejection is expected } } } diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index 6e030428..b2c8b735 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -2,9 +2,9 @@ # SPDX-License-Identifier: Apache-2.0 """Security integration tests for S3 Encryption Client. -These tests verify that the client is resilient against metadata-tampering -attacks, particularly downgrade attacks that attempt to bypass encryption -context validation by modifying the wrapping algorithm metadata. +These tests verify that the client correctly handles metadata tampering +scenarios, particularly wrapping algorithm downgrade attempts that modify +metadata to bypass encryption context validation. """ import json @@ -46,15 +46,11 @@ def _make_client(algorithm_suite, commitment_policy, enable_legacy_wrapping=Fals class TestWrappingAlgorithmDowngradeAttack: - """Tests for the wrapping algorithm downgrade attack (pentest finding). - - Attack scenario: An attacker with S3 write access modifies the object's - x-amz-w metadata from '12' (kms+context) to 'kms'. This attempts to - force the KmsKeyring into the KmsV1 decryption path, which does not - perform client-side encryption context comparison. If successful, a - caller providing a mismatched EncryptionContext on get_object would - still decrypt the object, defeating application-level access control - based on encryption context. + """Tests for wrapping algorithm downgrade scenarios. + + These tests verify behavior when the wrapping algorithm metadata is + modified from kms+context to kms. In V3 format, "kms" is not a valid + compressed wrapping algorithm code, so the client MUST reject it. """ def test_v3_downgrade_wrap_alg_to_kms_rejected_without_legacy(self): @@ -190,16 +186,10 @@ def test_v3_downgrade_wrap_alg_correct_context_still_fails(self): s3ec_legacy.get_object(Bucket=bucket, Key=key, EncryptionContext=encryption_context) def test_v3_downgrade_with_matdesc_injection(self): - """Tampering x-amz-w to 'kms' AND copying x-amz-t into x-amz-m defeats V3 protection. - - The previous V3 tests showed the attack fails because the KmsV1 path - reads from x-amz-m (mat_desc), which is empty for V3 objects. But if - the attacker also copies the original encryption context from x-amz-t - into x-amz-m, KMS Decrypt receives the correct bound context and - succeeds. The caller's mismatched EncryptionContext is silently ignored. + """Tampering x-amz-w to 'kms' AND copying x-amz-t into x-amz-m MUST be rejected. - CURRENT BEHAVIOR: Decryption SUCCEEDS with mismatched context — this is the bug. - EXPECTED BEHAVIOR: Decryption should FAIL. + "kms" is not a valid V3 compressed wrapping algorithm code, so the + client rejects it before the matdesc injection has any effect. """ key = _unique_key("sec-v3-downgrade-matdesc-") data = b"sensitive data with context" @@ -231,8 +221,7 @@ def test_v3_downgrade_with_matdesc_injection(self): MetadataDirective="REPLACE", ) - # 3. Decrypt with legacy enabled + mismatched context SHOULD fail - # but currently SUCCEEDS because x-amz-m now has the bound context + # 3. Decrypt with legacy enabled + mismatched context MUST fail s3ec_legacy = _make_client( AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, @@ -243,31 +232,29 @@ def test_v3_downgrade_with_matdesc_injection(self): class TestV2WrappingAlgorithmDowngradeAttack: - """Same downgrade attack but targeting V2 format objects. + """V2 wrapping algorithm downgrade tests. - V2 stores the wrapping algorithm in x-amz-wrap-alg. The attacker - changes it from 'kms+context' to 'kms'. + V2 stores the wrapping algorithm in x-amz-wrap-alg. When changed from + 'kms+context' to 'kms', the KmsV1 decryption path is used. Since + x-amz-matdesc already contains the original bound context, KMS Decrypt + succeeds and the caller-provided EncryptionContext is not validated. - CONFIRMED VULNERABILITY: For V2 objects, the KmsV1 path passes the - stored encryption context (from x-amz-matdesc) directly to KMS Decrypt. - Since x-amz-matdesc contains the original context used during - GenerateDataKey, KMS happily decrypts the data key. The caller's - mismatched EncryptionContext is silently ignored, defeating - application-level access control. + This is a known limitation of the V2 format when legacy wrapping + algorithms are enabled. """ @pytest.mark.xfail( - reason="CONFIRMED VULNERABILITY: V2 wrapping algorithm downgrade bypasses " - "encryption context validation when enable_legacy_wrapping_algorithms=True. " - "The KmsV1 path skips client-side context comparison and KMS Decrypt " - "succeeds because x-amz-matdesc contains the original bound context.", + reason="Known V2 format limitation: the KmsV1 path does not perform " + "client-side encryption context comparison, and x-amz-matdesc " + "contains the original bound context.", strict=True, ) def test_v2_downgrade_wrap_alg_to_kms_mismatched_context(self): - """Tampering x-amz-wrap-alg from 'kms+context' to 'kms' with wrong context MUST fail. + """Tampering x-amz-wrap-alg from 'kms+context' to 'kms' with wrong context. - CURRENT BEHAVIOR: Decryption SUCCEEDS with mismatched context — this is the bug. - EXPECTED BEHAVIOR: Decryption should FAIL because the caller's context doesn't match. + With legacy wrapping enabled, the KmsV1 path uses the stored matdesc + for KMS Decrypt, which succeeds. The mismatched caller context is + not checked. """ key = _unique_key("sec-v2-downgrade-") data = b"sensitive v2 data" @@ -299,8 +286,7 @@ def test_v2_downgrade_wrap_alg_to_kms_mismatched_context(self): MetadataDirective="REPLACE", ) - # 3. Decrypt with legacy enabled + mismatched context SHOULD fail - # but currently SUCCEEDS — this is the vulnerability + # 3. Decrypt with legacy enabled + mismatched context s3ec_legacy = _make_client( AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, From c6527670d404c9b03793be6026fbc6b71597d219 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Thu, 16 Apr 2026 12:10:17 -0700 Subject: [PATCH 08/19] fix some bugs, add tests --- src/s3_encryption/decryptor.py | 7 +- src/s3_encryption/pipelines.py | 24 +++ test/integration/test_i_security.py | 242 ++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+), 2 deletions(-) diff --git a/src/s3_encryption/decryptor.py b/src/s3_encryption/decryptor.py index e3d4eece..fc9b2372 100644 --- a/src/s3_encryption/decryptor.py +++ b/src/s3_encryption/decryptor.py @@ -72,8 +72,11 @@ def finalize(self, data: bytes) -> bytes: plaintext = self._decryptor.update(data) if data else b"" plaintext += self._decryptor.finalize() return self._unpadder.update(plaintext) + self._unpadder.finalize() - except Exception as e: - raise S3EncryptionClientSecurityError(f"Failed to decrypt CBC content: {e}") from e + except Exception: + # Use a fixed message for all CBC failures to prevent padding oracle attacks. + # Different failure modes (bad padding, truncated ciphertext, wrong key) MUST + # produce identical error responses so an attacker cannot distinguish them. + raise S3EncryptionClientSecurityError("Failed to decrypt CBC content.") @define diff --git a/src/s3_encryption/pipelines.py b/src/s3_encryption/pipelines.py index b2e5d58f..8a5ca9b2 100644 --- a/src/s3_encryption/pipelines.py +++ b/src/s3_encryption/pipelines.py @@ -316,6 +316,30 @@ def decrypt( # Determine the algorithm suite from the metadata algorithm_suite = self._determine_algorithm_suite(metadata) + # Reject metadata that contains keys from multiple format versions. + # This prevents format confusion attacks where an attacker injects + # V2 keys via an instruction file to bypass V3 key-commitment verification. + if metadata.has_exclusive_key_collision(): + raise S3EncryptionClientError( + "Object metadata contains keys from multiple format versions. " + "The object or its instruction file may have been tampered with." + ) + + # Also reject V2 format metadata that contains V3 content keys. + # In the instruction file injection scenario, the attacker replaces + # V3 EDK keys with V2 keys, but V3 content keys (x-amz-c, x-amz-d, + # x-amz-i) remain from the object metadata. This combination is + # never produced by legitimate encryption. + if metadata.is_v2_format() and ( + metadata.content_cipher_v3 is not None + or metadata.key_commitment_v3 is not None + or metadata.message_id_v3 is not None + ): + raise S3EncryptionClientError( + "Object metadata contains V2 format keys alongside V3 content keys. " + "The object or its instruction file may have been tampered with." + ) + # Determine which format we're dealing with and get decryption materials if metadata.is_v1_format(): dec_materials = self._decrypt_v1(metadata, encryption_context) diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index b2c8b735..72aac680 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -350,3 +350,245 @@ def test_v3_tamper_stored_context_metadata(self): # Decryption with the tampered context should fail at KMS with pytest.raises((S3EncryptionClientError, Exception)): s3ec.get_object(Bucket=bucket, Key=key, EncryptionContext={"project": "beta"}) + + +class TestCBCErrorIndistinguishability: + """Tests verifying that CBC decryption errors are indistinguishable. + + A padding oracle requires the caller to distinguish between padding + errors and other decryption failures. These tests verify that all CBC + failure modes produce the same error type and message, preventing + an attacker from using error responses to deduce padding validity. + """ + + def _encrypt_cbc(self, key, iv, plaintext): + """Helper to encrypt with AES-CBC + PKCS7 padding.""" + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.padding import PKCS7 + + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + encryptor = cipher.encryptor() + padder = PKCS7(128).padder() + padded = padder.update(plaintext) + padder.finalize() + return encryptor.update(padded) + encryptor.finalize() + + def _make_cbc_decryptor(self, key, iv, content_length): + """Helper to create an AesCbcDecryptor.""" + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + from cryptography.hazmat.primitives.padding import PKCS7 + + from s3_encryption.decryptor import AesCbcDecryptor + + cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + unpadder = PKCS7(128).unpadder() + return AesCbcDecryptor(cipher.decryptor(), unpadder, content_length) + + def test_wrong_key_and_tampered_ciphertext_produce_same_error(self): + """Wrong key and tampered ciphertext MUST produce identical error messages. + + Both cause PKCS7 unpadding to fail, but the error message and type + MUST be the same so an attacker cannot distinguish between them. + """ + import os + + from s3_encryption.exceptions import S3EncryptionClientSecurityError + + key = os.urandom(32) + iv = os.urandom(16) + ciphertext = self._encrypt_cbc(key, iv, b"test data for padding oracle check") + + # Wrong key: decryption produces garbage, unpadding fails + wrong_key = os.urandom(32) + decryptor1 = self._make_cbc_decryptor(wrong_key, iv, len(ciphertext)) + with pytest.raises(S3EncryptionClientSecurityError) as exc1: + decryptor1.finalize(ciphertext) + + # Tampered ciphertext: last byte flipped, unpadding fails + tampered = ciphertext[:-1] + bytes([(ciphertext[-1] ^ 0x01)]) + decryptor2 = self._make_cbc_decryptor(key, iv, len(tampered)) + with pytest.raises(S3EncryptionClientSecurityError) as exc2: + decryptor2.finalize(tampered) + + # Both MUST produce the same error message + assert str(exc1.value) == str(exc2.value), ( + f"Error messages differ: wrong_key={str(exc1.value)!r}, " + f"tampered={str(exc2.value)!r}" + ) + + # Neither message should contain details about the underlying failure + assert "padding" not in str(exc1.value).lower(), ( + f"Error message leaks padding information: {str(exc1.value)!r}" + ) + + def test_truncated_ciphertext_produces_same_error(self): + """Truncated ciphertext MUST produce the same error as padding failure. + + A non-block-aligned ciphertext causes a different exception in the + cryptography library. The error message MUST be identical to prevent + an attacker from distinguishing truncation from padding failure. + """ + import os + + from s3_encryption.exceptions import S3EncryptionClientSecurityError + + key = os.urandom(32) + iv = os.urandom(16) + ciphertext = self._encrypt_cbc(key, iv, b"test data for truncation check") + + # Padding failure (wrong key) + wrong_key = os.urandom(32) + decryptor1 = self._make_cbc_decryptor(wrong_key, iv, len(ciphertext)) + with pytest.raises(S3EncryptionClientSecurityError) as exc1: + decryptor1.finalize(ciphertext) + + # Truncated ciphertext (not block-aligned) + truncated = ciphertext[:-3] + decryptor2 = self._make_cbc_decryptor(key, iv, len(truncated)) + with pytest.raises(S3EncryptionClientSecurityError) as exc2: + decryptor2.finalize(truncated) + + # Both MUST produce the same error message + assert str(exc1.value) == str(exc2.value), ( + f"Error messages differ: padding_fail={str(exc1.value)!r}, " + f"truncated={str(exc2.value)!r}" + ) + + +class TestInstructionFileFormatConfusion: + """Tests for instruction file metadata injection causing format confusion. + + When a V3 object uses instruction files, the instruction file metadata + is merged with object metadata. If an attacker injects V2-format keys + into the instruction file, the merged metadata may match is_v2_format() + before is_v3_format(), causing the V2 decryption path to execute and + bypassing V3 key-commitment verification. + + The has_exclusive_key_collision() method exists to detect this but is + not called in any production code path. + """ + + def test_v2_keys_in_instruction_file_cause_format_confusion(self): + """Injecting V2 keys into a V3 instruction file MUST be detected. + + After merging instruction file metadata (containing V2 keys) with + object metadata (containing V3 keys), the resulting ObjectMetadata + has V2 keys plus V3 content keys. is_v2_format() matches first + because it does not check for V3 key absence, causing the V2 + decryption path to execute instead of V3. + """ + from s3_encryption.metadata import ObjectMetadata + + # Simulate V3 object metadata (stored on the S3 object). + # In V3 instruction file mode, the object metadata has content keys + # (x-amz-c, x-amz-d, x-amz-i) but NOT the EDK (x-amz-3). + v3_object_metadata = { + "x-amz-c": "115", # V3 content cipher + "x-amz-d": "dGVzdA==", # V3 key commitment + "x-amz-i": "bWVzc2FnZQ==", # V3 message ID + } + + # Simulate attacker-crafted instruction file with V2 keys. + # Normally the instruction file would have x-amz-3, x-amz-w, x-amz-t + # for V3. The attacker replaces these with V2 keys. + attacker_instruction_file = { + "x-amz-key-v2": "YXR0YWNrZXJfa2V5", # V2 encrypted data key + "x-amz-cek-alg": "AES/GCM/NoPadding", # V2 content cipher + "x-amz-iv": "YXR0YWNrZXJfaXY=", # V2 IV + "x-amz-wrap-alg": "kms+context", # V2 wrapping algorithm + "x-amz-matdesc": '{"aws:x-amz-cek-alg": "AES/GCM/NoPadding"}', + } + + # The forbidden-keys check only blocks V3-exclusive keys + v3_exclusive = {"x-amz-c", "x-amz-d", "x-amz-i"} + injected_keys = set(attacker_instruction_file.keys()) + assert not (injected_keys & v3_exclusive), ( + "Test setup error: attacker keys should not overlap with V3 exclusive keys" + ) + + # Merge: instruction_metadata.update(encryption_metadata) + # This is the same merge order as pipelines.py line 297 + merged = attacker_instruction_file.copy() + merged.update(v3_object_metadata) + + merged_metadata = ObjectMetadata.from_dict(merged) + + # The merged metadata has V2 keys AND V3 content keys (x-amz-c, x-amz-d, x-amz-i) + # but NOT the V3 EDK (x-amz-3), since the attacker replaced it with V2 keys. + # is_v2_format() matches because it only checks for V2 key presence + V1 absence + assert merged_metadata.is_v2_format(), ( + "is_v2_format() should match when V2 keys are injected alongside V3 content keys" + ) + # is_v3_format() does NOT match because encrypted_data_key_v3 is None + # (the attacker didn't include x-amz-3) AND encrypted_data_key_v2 is not None + assert not merged_metadata.is_v3_format(), ( + "is_v3_format() should NOT match when V2 EDK key is present" + ) + # V3 content keys are present but ignored — format dispatch goes to V2 + assert merged_metadata.content_cipher_v3 is not None, ( + "V3 content cipher should still be present in merged metadata" + ) + + def test_exclusive_key_collision_detected_during_decrypt(self): + """The decrypt pipeline MUST reject metadata with exclusive key collisions. + + When merged metadata contains both V2 and V3 exclusive keys, + the pipeline should detect the collision and raise an error + rather than silently routing to the V2 decryption path. + """ + import base64 + import os + from unittest.mock import MagicMock, patch + + from s3_encryption.materials.crypto_materials_manager import DefaultCryptoMaterialsManager + from s3_encryption.materials.materials import CommitmentPolicy, DecryptionMaterials + from s3_encryption.pipelines import GetEncryptedObjectPipeline + + # Create a mock CMM that would return decryption materials + mock_cmm = MagicMock(spec=DefaultCryptoMaterialsManager) + + pipeline = GetEncryptedObjectPipeline( + cmm=mock_cmm, + commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT, + enable_legacy_unauthenticated_modes=False, + ) + + # Build a response with merged V2+V3 metadata (simulating the + # instruction file injection after merge) + fake_edk = base64.b64encode(os.urandom(32)).decode() + fake_iv = base64.b64encode(os.urandom(12)).decode() + fake_message_id = base64.b64encode(os.urandom(28)).decode() + fake_commitment = base64.b64encode(os.urandom(28)).decode() + + merged_metadata = { + # V2 keys (from attacker instruction file) + "x-amz-key-v2": fake_edk, + "x-amz-cek-alg": "AES/GCM/NoPadding", + "x-amz-iv": fake_iv, + "x-amz-wrap-alg": "kms+context", + "x-amz-matdesc": '{"aws:x-amz-cek-alg": "AES/GCM/NoPadding"}', + # V3 keys (from object metadata) + "x-amz-c": "115", + "x-amz-d": fake_commitment, + "x-amz-i": fake_message_id, + "x-amz-w": "12", + "x-amz-3": fake_edk, + } + + fake_body = MagicMock() + fake_body.read.return_value = os.urandom(48) # fake ciphertext + + response = { + "Body": fake_body, + "Metadata": merged_metadata, + "ContentLength": 48, + } + + # This SHOULD raise an error due to exclusive key collision, + # but currently routes to _decrypt_v2 instead + with pytest.raises(S3EncryptionClientError): + pipeline.decrypt( + response, + instruction_suffix=".instruction", + enable_delayed_authentication=False, + encryption_context={}, + ) From 2e9c2a5507f761c4c84051cb0d9e77c370b02acd Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 17 Apr 2026 11:57:55 -0700 Subject: [PATCH 09/19] fix java --- .../encryption/s3/WrappingAlgorithmDowngradeTests.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java index 38ccb055..c1b7b7bc 100644 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java @@ -240,12 +240,17 @@ void v2_downgrade_wrap_alg( + " (language: " + language.getLanguageName() + ")"); } // For non-resilient languages, this is the expected (known) behavior - } catch (S3EncryptionClientError | Exception e) { + } catch (S3EncryptionClientError e) { if (!expectRejection) { fail("V2 downgrade was unexpectedly rejected for: " + objectKey + " (language: " + language.getLanguageName() + "): " + e.getMessage()); } // For resilient languages, rejection is expected + } catch (Exception e) { + if (!expectRejection) { + fail("V2 downgrade was unexpectedly rejected for: " + objectKey + + " (language: " + language.getLanguageName() + "): " + e.getMessage()); + } } } } From f50f3fbc10a7e7e2d70f913037eeec42d9094755 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 17 Apr 2026 11:59:47 -0700 Subject: [PATCH 10/19] format --- test/integration/test_i_security.py | 44 ++++++++++++++--------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index 72aac680..014d4211 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -404,7 +404,7 @@ def test_wrong_key_and_tampered_ciphertext_produce_same_error(self): decryptor1.finalize(ciphertext) # Tampered ciphertext: last byte flipped, unpadding fails - tampered = ciphertext[:-1] + bytes([(ciphertext[-1] ^ 0x01)]) + tampered = ciphertext[:-1] + bytes([ciphertext[-1] ^ 0x01]) decryptor2 = self._make_cbc_decryptor(key, iv, len(tampered)) with pytest.raises(S3EncryptionClientSecurityError) as exc2: decryptor2.finalize(tampered) @@ -416,9 +416,9 @@ def test_wrong_key_and_tampered_ciphertext_produce_same_error(self): ) # Neither message should contain details about the underlying failure - assert "padding" not in str(exc1.value).lower(), ( - f"Error message leaks padding information: {str(exc1.value)!r}" - ) + assert ( + "padding" not in str(exc1.value).lower() + ), f"Error message leaks padding information: {str(exc1.value)!r}" def test_truncated_ciphertext_produces_same_error(self): """Truncated ciphertext MUST produce the same error as padding failure. @@ -482,8 +482,8 @@ def test_v2_keys_in_instruction_file_cause_format_confusion(self): # In V3 instruction file mode, the object metadata has content keys # (x-amz-c, x-amz-d, x-amz-i) but NOT the EDK (x-amz-3). v3_object_metadata = { - "x-amz-c": "115", # V3 content cipher - "x-amz-d": "dGVzdA==", # V3 key commitment + "x-amz-c": "115", # V3 content cipher + "x-amz-d": "dGVzdA==", # V3 key commitment "x-amz-i": "bWVzc2FnZQ==", # V3 message ID } @@ -493,17 +493,17 @@ def test_v2_keys_in_instruction_file_cause_format_confusion(self): attacker_instruction_file = { "x-amz-key-v2": "YXR0YWNrZXJfa2V5", # V2 encrypted data key "x-amz-cek-alg": "AES/GCM/NoPadding", # V2 content cipher - "x-amz-iv": "YXR0YWNrZXJfaXY=", # V2 IV - "x-amz-wrap-alg": "kms+context", # V2 wrapping algorithm + "x-amz-iv": "YXR0YWNrZXJfaXY=", # V2 IV + "x-amz-wrap-alg": "kms+context", # V2 wrapping algorithm "x-amz-matdesc": '{"aws:x-amz-cek-alg": "AES/GCM/NoPadding"}', } # The forbidden-keys check only blocks V3-exclusive keys v3_exclusive = {"x-amz-c", "x-amz-d", "x-amz-i"} injected_keys = set(attacker_instruction_file.keys()) - assert not (injected_keys & v3_exclusive), ( - "Test setup error: attacker keys should not overlap with V3 exclusive keys" - ) + assert not ( + injected_keys & v3_exclusive + ), "Test setup error: attacker keys should not overlap with V3 exclusive keys" # Merge: instruction_metadata.update(encryption_metadata) # This is the same merge order as pipelines.py line 297 @@ -515,18 +515,18 @@ def test_v2_keys_in_instruction_file_cause_format_confusion(self): # The merged metadata has V2 keys AND V3 content keys (x-amz-c, x-amz-d, x-amz-i) # but NOT the V3 EDK (x-amz-3), since the attacker replaced it with V2 keys. # is_v2_format() matches because it only checks for V2 key presence + V1 absence - assert merged_metadata.is_v2_format(), ( - "is_v2_format() should match when V2 keys are injected alongside V3 content keys" - ) + assert ( + merged_metadata.is_v2_format() + ), "is_v2_format() should match when V2 keys are injected alongside V3 content keys" # is_v3_format() does NOT match because encrypted_data_key_v3 is None # (the attacker didn't include x-amz-3) AND encrypted_data_key_v2 is not None - assert not merged_metadata.is_v3_format(), ( - "is_v3_format() should NOT match when V2 EDK key is present" - ) + assert ( + not merged_metadata.is_v3_format() + ), "is_v3_format() should NOT match when V2 EDK key is present" # V3 content keys are present but ignored — format dispatch goes to V2 - assert merged_metadata.content_cipher_v3 is not None, ( - "V3 content cipher should still be present in merged metadata" - ) + assert ( + merged_metadata.content_cipher_v3 is not None + ), "V3 content cipher should still be present in merged metadata" def test_exclusive_key_collision_detected_during_decrypt(self): """The decrypt pipeline MUST reject metadata with exclusive key collisions. @@ -537,10 +537,10 @@ def test_exclusive_key_collision_detected_during_decrypt(self): """ import base64 import os - from unittest.mock import MagicMock, patch + from unittest.mock import MagicMock from s3_encryption.materials.crypto_materials_manager import DefaultCryptoMaterialsManager - from s3_encryption.materials.materials import CommitmentPolicy, DecryptionMaterials + from s3_encryption.materials.materials import CommitmentPolicy from s3_encryption.pipelines import GetEncryptedObjectPipeline # Create a mock CMM that would return decryption materials From 9d97f9041b04f539d92359484d67ec762fa0fd64 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 17 Apr 2026 12:30:37 -0700 Subject: [PATCH 11/19] linter --- src/s3_encryption/decryptor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/s3_encryption/decryptor.py b/src/s3_encryption/decryptor.py index fc9b2372..f76c19dc 100644 --- a/src/s3_encryption/decryptor.py +++ b/src/s3_encryption/decryptor.py @@ -76,7 +76,7 @@ def finalize(self, data: bytes) -> bytes: # Use a fixed message for all CBC failures to prevent padding oracle attacks. # Different failure modes (bad padding, truncated ciphertext, wrong key) MUST # produce identical error responses so an attacker cannot distinguish them. - raise S3EncryptionClientSecurityError("Failed to decrypt CBC content.") + raise S3EncryptionClientSecurityError("Failed to decrypt CBC content.") from None @define From b3b06bc72a041c73b11e5099505f383038b51909 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 17 Apr 2026 15:15:20 -0700 Subject: [PATCH 12/19] reorient around expected downgrade behavior --- src/s3_encryption/materials/kms_keyring.py | 9 +++ test-server/java-tests/build.gradle.kts | 1 - .../s3/WrappingAlgorithmDowngradeTests.java | 59 +++++++------------ test/integration/test_i_security.py | 24 +++----- 4 files changed, 37 insertions(+), 56 deletions(-) diff --git a/src/s3_encryption/materials/kms_keyring.py b/src/s3_encryption/materials/kms_keyring.py index edf1d27b..1d86ae05 100644 --- a/src/s3_encryption/materials/kms_keyring.py +++ b/src/s3_encryption/materials/kms_keyring.py @@ -200,6 +200,15 @@ def on_decrypt(self, dec_materials, encrypted_data_keys=None): f"Enable legacy wrapping algorithms to use legacy key wrapping " f"algorithm: {edk.key_provider_info}" ) + # The KmsV1 wrapping algorithm does not support caller-provided + # encryption context. If the caller provided encryption context, + # the client MUST reject the request. + if dec_materials.encryption_context_from_request: + raise S3EncryptionClientError( + "Encryption context is not supported with the KmsV1 (kms) " + "wrapping algorithm. Use kms+context wrapping algorithm to " + "use encryption context." + ) else: raise S3EncryptionClientError( f"{edk.key_provider_info} is not a valid key wrapping algorithm!" diff --git a/test-server/java-tests/build.gradle.kts b/test-server/java-tests/build.gradle.kts index 2d1cbdeb..64480ee8 100644 --- a/test-server/java-tests/build.gradle.kts +++ b/test-server/java-tests/build.gradle.kts @@ -66,7 +66,6 @@ tasks { // Passing information from Gradle into the tests so that we can filter our servers systemProperty("test.filter.servers", System.getProperty("test.filter.servers")) // For debugging - // // Enable System.out output // testLogging { // events("passed", "skipped", "failed", "standardOut", "standardError") // showStandardStreams = true diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java index c1b7b7bc..4bcda0c2 100644 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java @@ -13,8 +13,6 @@ import java.util.List; import java.util.Map; -import java.util.Set; - import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.amazonaws.services.s3.model.CopyObjectRequest; @@ -46,9 +44,13 @@ * V3 format: All implementations MUST reject the downgrade because "kms" is not * a valid V3 compressed wrapping algorithm code. * - * V2 format: Implementations that validate encryption context at the pipeline - * layer (Go, C++) reject the downgrade. Implementations that delegate context - * validation to the keyring may not catch the downgrade in V2 format. + * V2 format: The KmsV1 ("kms") wrapping algorithm does not support caller-provided + * encryption context. When a caller provides encryption context on decrypt and the + * wrapping algorithm is "kms", the client MUST reject the request. This is the + * canonical behavior established by the Java AmazonS3EncryptionClientV2, which + * refuses to decrypt "kms"-wrapped objects entirely. Implementations that validate + * encryption context at the pipeline layer (Go, C++) also reject this correctly. + * All implementations MUST reject this downgrade. */ @DisplayName("Wrapping Algorithm Downgrade Tests") public class WrappingAlgorithmDowngradeTests { @@ -58,13 +60,6 @@ public class WrappingAlgorithmDowngradeTests { .kmsKeyId(TestUtils.KMS_KEY_ARN) .build(); - // Languages that validate encryption context at the pipeline layer, - // making them resilient to V2 wrapping algorithm downgrade. - private static final Set V2_DOWNGRADE_RESILIENT = Set.of( - TestUtils.GO_V4, - TestUtils.CPP_V3 - ); - // Encryption context used during encryption private static final String ENCRYPT_CONTEXT = "[project]:[alpha]"; // Mismatched encryption context used during decryption (the attacker's goal) @@ -167,17 +162,16 @@ void v3_downgrade_wrap_alg_with_matdesc_injection_must_fail( /** * V2 format: Changing x-amz-wrap-alg from "kms+context" to "kms". * - * For V2, x-amz-matdesc already contains the original bound context, - * so only the wrapping algorithm needs to change. - * - * Languages that validate encryption context at the pipeline layer (Go, C++) - * reject this regardless of wrapping algorithm. Other languages delegate - * context validation to the keyring, where the KmsV1 path does not - * perform the comparison. + * The KmsV1 ("kms") wrapping algorithm does not support caller-provided + * encryption context. When a caller provides encryption context on decrypt + * and the wrapping algorithm has been tampered to "kms", the client MUST + * reject the request. This matches the canonical behavior of the Java + * AmazonS3EncryptionClientV2, which refuses to decrypt "kms"-wrapped + * objects entirely. */ - @ParameterizedTest(name = "{0}: V2 wrapping algorithm downgrade") + @ParameterizedTest(name = "{0}: V2 wrapping algorithm downgrade must fail") @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest") - void v2_downgrade_wrap_alg( + void v2_downgrade_wrap_alg_must_fail( TestUtils.LanguageServerTarget language ) { if (ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED.contains(language.getLanguageName()) @@ -186,8 +180,6 @@ void v2_downgrade_wrap_alg( "Encryption context not supported for: " + language.getLanguageName()); } - boolean expectRejection = V2_DOWNGRADE_RESILIENT.contains(language.getLanguageName()); - String objectKey = appendTestSuffix("sec-v2-downgrade-" + language.getLanguageName()); S3ECTestServerClient client = TestUtils.testServerClientFor(language); @@ -216,7 +208,7 @@ void v2_downgrade_wrap_alg( tamperMetadata(objectKey, userMeta); // 3. Create a client with legacy wrapping enabled and attempt decrypt - // with mismatched context + // with mismatched context — MUST fail CreateClientOutput legacyClientOutput = client.createClient(CreateClientInput.builder() .config(S3ECConfig.builder() .keyMaterial(kmsKeyArn) @@ -234,23 +226,12 @@ void v2_downgrade_wrap_alg( .key(objectKey) .metadata(List.of(MISMATCHED_CONTEXT)) .build()); - // Decryption succeeded with mismatched context - if (expectRejection) { - fail("V2 downgrade should have been rejected for: " + objectKey - + " (language: " + language.getLanguageName() + ")"); - } - // For non-resilient languages, this is the expected (known) behavior + fail("V2 downgrade should have been rejected for: " + objectKey + + " (language: " + language.getLanguageName() + ")"); } catch (S3EncryptionClientError e) { - if (!expectRejection) { - fail("V2 downgrade was unexpectedly rejected for: " + objectKey - + " (language: " + language.getLanguageName() + "): " + e.getMessage()); - } - // For resilient languages, rejection is expected + // Expected — tampered wrapping algorithm was rejected } catch (Exception e) { - if (!expectRejection) { - fail("V2 downgrade was unexpectedly rejected for: " + objectKey - + " (language: " + language.getLanguageName() + "): " + e.getMessage()); - } + // Rejected via a different error type — still a pass. } } } diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index 014d4211..f651a7da 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -234,27 +234,19 @@ def test_v3_downgrade_with_matdesc_injection(self): class TestV2WrappingAlgorithmDowngradeAttack: """V2 wrapping algorithm downgrade tests. - V2 stores the wrapping algorithm in x-amz-wrap-alg. When changed from - 'kms+context' to 'kms', the KmsV1 decryption path is used. Since - x-amz-matdesc already contains the original bound context, KMS Decrypt - succeeds and the caller-provided EncryptionContext is not validated. - - This is a known limitation of the V2 format when legacy wrapping - algorithms are enabled. + V2 stores the wrapping algorithm in x-amz-wrap-alg. The KmsV1 ("kms") + wrapping algorithm does not support caller-provided encryption context. + When a caller provides encryption context on decrypt and the wrapping + algorithm is "kms", the client MUST reject the request. This is the + canonical behavior established by the Java AmazonS3EncryptionClientV2. """ - @pytest.mark.xfail( - reason="Known V2 format limitation: the KmsV1 path does not perform " - "client-side encryption context comparison, and x-amz-matdesc " - "contains the original bound context.", - strict=True, - ) def test_v2_downgrade_wrap_alg_to_kms_mismatched_context(self): """Tampering x-amz-wrap-alg from 'kms+context' to 'kms' with wrong context. - With legacy wrapping enabled, the KmsV1 path uses the stored matdesc - for KMS Decrypt, which succeeds. The mismatched caller context is - not checked. + The KmsV1 wrapping algorithm does not support encryption context. + The client MUST reject when a caller provides encryption context + and the wrapping algorithm is 'kms'. """ key = _unique_key("sec-v2-downgrade-") data = b"sensitive v2 data" From 5468cbe80ac6c826bc6dde50cf64eb8b9b44c173 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 17 Apr 2026 16:02:18 -0700 Subject: [PATCH 13/19] fix python --- src/s3_encryption/materials/kms_keyring.py | 26 ++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/s3_encryption/materials/kms_keyring.py b/src/s3_encryption/materials/kms_keyring.py index 1d86ae05..39a5de28 100644 --- a/src/s3_encryption/materials/kms_keyring.py +++ b/src/s3_encryption/materials/kms_keyring.py @@ -200,15 +200,23 @@ def on_decrypt(self, dec_materials, encrypted_data_keys=None): f"Enable legacy wrapping algorithms to use legacy key wrapping " f"algorithm: {edk.key_provider_info}" ) - # The KmsV1 wrapping algorithm does not support caller-provided - # encryption context. If the caller provided encryption context, - # the client MUST reject the request. - if dec_materials.encryption_context_from_request: - raise S3EncryptionClientError( - "Encryption context is not supported with the KmsV1 (kms) " - "wrapping algorithm. Use kms+context wrapping algorithm to " - "use encryption context." - ) + # The KmsV1 path must also validate caller-provided encryption + # context against the stored materials description, matching the + # behavior of the kms+context path. Without this check, an attacker + # who tampers x-amz-wrap-alg from kms+context to kms can bypass + # the encryption context comparison. + encryption_context_from_request = dec_materials.encryption_context_from_request + if encryption_context_from_request: + encryption_context_stored = dec_materials.encryption_context_stored + encryption_context_stored_copy = encryption_context_stored.copy() + encryption_context_stored_copy.pop(KMS_V1_DEFAULT_KEY, None) + encryption_context_stored_copy.pop(KMS_CONTEXT_DEFAULT_KEY, None) + + if encryption_context_stored_copy != encryption_context_from_request: + raise S3EncryptionClientError( + "Provided encryption context does not match information " + "retrieved from S3" + ) else: raise S3EncryptionClientError( f"{edk.key_provider_info} is not a valid key wrapping algorithm!" From d64ee5ea9fd69c3969727b1e36ad1b45618f6a46 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 17 Apr 2026 16:41:54 -0700 Subject: [PATCH 14/19] remove testserver tests for now --- .../s3/WrappingAlgorithmDowngradeTests.java | 237 ------------------ 1 file changed, 237 deletions(-) delete mode 100644 test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java deleted file mode 100644 index 4bcda0c2..00000000 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/WrappingAlgorithmDowngradeTests.java +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.encryption.s3; - -import static org.junit.jupiter.api.Assertions.fail; -import static software.amazon.encryption.s3.TestUtils.*; - -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; - -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; -import com.amazonaws.services.s3.model.CopyObjectRequest; -import com.amazonaws.services.s3.model.ObjectMetadata; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import org.opentest4j.TestAbortedException; -import software.amazon.encryption.s3.client.S3ECTestServerClient; -import software.amazon.encryption.s3.model.CommitmentPolicy; -import software.amazon.encryption.s3.model.CreateClientInput; -import software.amazon.encryption.s3.model.CreateClientOutput; -import software.amazon.encryption.s3.model.EncryptionAlgorithm; -import software.amazon.encryption.s3.model.GetObjectInput; -import software.amazon.encryption.s3.model.KeyMaterial; -import software.amazon.encryption.s3.model.PutObjectInput; -import software.amazon.encryption.s3.model.S3ECConfig; -import software.amazon.encryption.s3.model.S3EncryptionClientError; - -/** - * Wrapping Algorithm Downgrade Tests - * - * These tests verify S3 Encryption Client behavior when the wrapping algorithm - * metadata is modified from kms+context to kms. This simulates a scenario where - * an attacker with S3 write access tampers with object metadata to attempt to - * bypass encryption context validation. - * - * V3 format: All implementations MUST reject the downgrade because "kms" is not - * a valid V3 compressed wrapping algorithm code. - * - * V2 format: The KmsV1 ("kms") wrapping algorithm does not support caller-provided - * encryption context. When a caller provides encryption context on decrypt and the - * wrapping algorithm is "kms", the client MUST reject the request. This is the - * canonical behavior established by the Java AmazonS3EncryptionClientV2, which - * refuses to decrypt "kms"-wrapped objects entirely. Implementations that validate - * encryption context at the pipeline layer (Go, C++) also reject this correctly. - * All implementations MUST reject this downgrade. - */ -@DisplayName("Wrapping Algorithm Downgrade Tests") -public class WrappingAlgorithmDowngradeTests { - - private static final AmazonS3 s3Client = AmazonS3ClientBuilder.defaultClient(); - private static final KeyMaterial kmsKeyArn = KeyMaterial.builder() - .kmsKeyId(TestUtils.KMS_KEY_ARN) - .build(); - - // Encryption context used during encryption - private static final String ENCRYPT_CONTEXT = "[project]:[alpha]"; - // Mismatched encryption context used during decryption (the attacker's goal) - private static final String MISMATCHED_CONTEXT = "[project]:[beta]"; - - @BeforeAll - static void setup() { - TestUtils.validateServersRunning(); - } - - /** - * Helper to tamper S3 object metadata by copying the object with replaced metadata. - */ - private void tamperMetadata(String objectKey, Map newMetadata) { - ObjectMetadata replacementMetadata = new ObjectMetadata(); - replacementMetadata.setUserMetadata(newMetadata); - - CopyObjectRequest copyRequest = new CopyObjectRequest( - TestUtils.BUCKET, objectKey, TestUtils.BUCKET, objectKey) - .withNewObjectMetadata(replacementMetadata); - s3Client.copyObject(copyRequest); - } - - /** - * V3 format: Changing x-amz-w from "12" (kms+context) to "kms" AND - * copying x-amz-t into x-amz-m MUST be rejected. - * - * "kms" is not a valid V3 compressed wrapping algorithm code, so all - * implementations MUST reject this. - */ - @ParameterizedTest(name = "{0}: V3 wrapping algorithm downgrade with matdesc injection must fail") - @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest") - void v3_downgrade_wrap_alg_with_matdesc_injection_must_fail( - TestUtils.LanguageServerTarget language - ) { - if (ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED.contains(language.getLanguageName()) - || ENCRYPTION_CONTEXT_ON_DECRYPT_UNSUPPORTED.contains(language.getLanguageName())) { - throw new TestAbortedException( - "Encryption context not supported for: " + language.getLanguageName()); - } - - String objectKey = appendTestSuffix("sec-v3-downgrade-" + language.getLanguageName()); - S3ECTestServerClient client = TestUtils.testServerClientFor(language); - - // 1. Create client and encrypt with encryption context - CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder() - .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn) - .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT) - .build()) - .build()); - String encryptClientId = clientOutput.getClientId(); - - client.putObject(PutObjectInput.builder() - .clientID(encryptClientId) - .key(objectKey) - .bucket(TestUtils.BUCKET) - .body(ByteBuffer.wrap(objectKey.getBytes(StandardCharsets.UTF_8))) - .metadata(List.of(ENCRYPT_CONTEXT)) - .build()); - - // 2. Tamper: change x-amz-w from "12" to "kms" and copy x-amz-t into x-amz-m - ObjectMetadata head = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); - Map userMeta = head.getUserMetadata(); - userMeta.put("x-amz-w", "kms"); - // Copy the stored encryption context into x-amz-m - String storedContext = userMeta.get("x-amz-t"); - if (storedContext != null) { - userMeta.put("x-amz-m", storedContext); - } - tamperMetadata(objectKey, userMeta); - - // 3. Create a client with legacy wrapping enabled and attempt decrypt - // with mismatched context — MUST fail - CreateClientOutput legacyClientOutput = client.createClient(CreateClientInput.builder() - .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn) - .enableLegacyWrappingAlgorithms(true) - .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT) - .build()) - .build()); - String legacyClientId = legacyClientOutput.getClientId(); - - try { - client.getObject(GetObjectInput.builder() - .clientID(legacyClientId) - .bucket(TestUtils.BUCKET) - .key(objectKey) - .metadata(List.of(MISMATCHED_CONTEXT)) - .build()); - fail("V3 downgrade should have been rejected for: " + objectKey - + " (language: " + language.getLanguageName() + ")"); - } catch (S3EncryptionClientError e) { - // Expected — tampered wrapping algorithm was rejected - } catch (Exception e) { - // Rejected via a different error type — still a pass. - } - } - - /** - * V2 format: Changing x-amz-wrap-alg from "kms+context" to "kms". - * - * The KmsV1 ("kms") wrapping algorithm does not support caller-provided - * encryption context. When a caller provides encryption context on decrypt - * and the wrapping algorithm has been tampered to "kms", the client MUST - * reject the request. This matches the canonical behavior of the Java - * AmazonS3EncryptionClientV2, which refuses to decrypt "kms"-wrapped - * objects entirely. - */ - @ParameterizedTest(name = "{0}: V2 wrapping algorithm downgrade must fail") - @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest") - void v2_downgrade_wrap_alg_must_fail( - TestUtils.LanguageServerTarget language - ) { - if (ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED.contains(language.getLanguageName()) - || ENCRYPTION_CONTEXT_ON_DECRYPT_UNSUPPORTED.contains(language.getLanguageName())) { - throw new TestAbortedException( - "Encryption context not supported for: " + language.getLanguageName()); - } - - String objectKey = appendTestSuffix("sec-v2-downgrade-" + language.getLanguageName()); - S3ECTestServerClient client = TestUtils.testServerClientFor(language); - - // 1. Create client and encrypt with V2 format + encryption context - CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder() - .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn) - .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) - .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) - .build()) - .build()); - String encryptClientId = clientOutput.getClientId(); - - client.putObject(PutObjectInput.builder() - .clientID(encryptClientId) - .key(objectKey) - .bucket(TestUtils.BUCKET) - .body(ByteBuffer.wrap(objectKey.getBytes(StandardCharsets.UTF_8))) - .metadata(List.of(ENCRYPT_CONTEXT)) - .build()); - - // 2. Tamper: change x-amz-wrap-alg from "kms+context" to "kms" - ObjectMetadata head = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); - Map userMeta = head.getUserMetadata(); - userMeta.put("x-amz-wrap-alg", "kms"); - tamperMetadata(objectKey, userMeta); - - // 3. Create a client with legacy wrapping enabled and attempt decrypt - // with mismatched context — MUST fail - CreateClientOutput legacyClientOutput = client.createClient(CreateClientInput.builder() - .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn) - .enableLegacyWrappingAlgorithms(true) - .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) - .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) - .build()) - .build()); - String legacyClientId = legacyClientOutput.getClientId(); - - try { - client.getObject(GetObjectInput.builder() - .clientID(legacyClientId) - .bucket(TestUtils.BUCKET) - .key(objectKey) - .metadata(List.of(MISMATCHED_CONTEXT)) - .build()); - fail("V2 downgrade should have been rejected for: " + objectKey - + " (language: " + language.getLanguageName() + ")"); - } catch (S3EncryptionClientError e) { - // Expected — tampered wrapping algorithm was rejected - } catch (Exception e) { - // Rejected via a different error type — still a pass. - } - } -} From 40f8aff9cd7ea6c378f12bbcf1eb59b6cb2c6b4a Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 17 Apr 2026 16:47:20 -0700 Subject: [PATCH 15/19] remove stray comment --- test-server/java-tests/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/test-server/java-tests/build.gradle.kts b/test-server/java-tests/build.gradle.kts index 64480ee8..2d1cbdeb 100644 --- a/test-server/java-tests/build.gradle.kts +++ b/test-server/java-tests/build.gradle.kts @@ -66,6 +66,7 @@ tasks { // Passing information from Gradle into the tests so that we can filter our servers systemProperty("test.filter.servers", System.getProperty("test.filter.servers")) // For debugging + // // Enable System.out output // testLogging { // events("passed", "skipped", "failed", "standardOut", "standardError") // showStandardStreams = true From 5d6e01c9ebf86dbeb97cd109f7cc0165eb72eaa1 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 17 Apr 2026 17:04:46 -0700 Subject: [PATCH 16/19] improve coverage --- src/s3_encryption/pipelines.py | 4 +-- test/test_kms_keyring.py | 47 ++++++++++++++++++++++++++++++++++ test/test_pipelines.py | 35 +++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/s3_encryption/pipelines.py b/src/s3_encryption/pipelines.py index 1264c8b7..c4fe4867 100644 --- a/src/s3_encryption/pipelines.py +++ b/src/s3_encryption/pipelines.py @@ -335,7 +335,7 @@ def decrypt( or metadata.key_commitment_v3 is not None or metadata.message_id_v3 is not None ): - raise S3EncryptionClientError( + raise S3EncryptionClientError( # pragma: no cover — only reachable via instruction file merge; covered by TestInstructionFileFormatConfusion "Object metadata contains V2 format keys alongside V3 content keys. " "The object or its instruction file may have been tampered with." ) @@ -640,7 +640,7 @@ def _decrypt_v3(self, metadata, encryption_context) -> DecryptionMaterials: elif wrap_alg in ("AES/GCM", "RSA-OAEP-SHA1"): raw_ctx = metadata.mat_desc_v3 else: - raise S3EncryptionClientError( + raise S3EncryptionClientError( # pragma: no cover — defense in depth, unreachable f"Unexpected V3 wrapping algorithm for context selection: '{wrap_alg}'. " f"The object metadata may have been tampered with." ) diff --git a/test/test_kms_keyring.py b/test/test_kms_keyring.py index 0a5d66de..b0881c6a 100644 --- a/test/test_kms_keyring.py +++ b/test/test_kms_keyring.py @@ -467,3 +467,50 @@ def test_on_decrypt_fails_when_kms_fails(self): keyring.on_decrypt(dec_materials) assert exc_info.value is kms_exception + + def test_on_decrypt_kms_v1_validates_encryption_context(self): + """KmsV1 path must validate caller-provided encryption context against stored matdesc.""" + mock_kms_client = MagicMock() + mock_kms_client.decrypt.return_value = {"Plaintext": b"decrypted-key-material-32-bytes!"} + + keyring = KmsKeyring(mock_kms_client, "arn:aws:kms:us-east-1:123456789012:key/test-key", + enable_legacy_wrapping_algorithms=True) + + edk = EncryptedDataKey( + key_provider_id=b"S3Keyring", + key_provider_info="kms", + encrypted_data_key=b"encrypted-key", + ) + dec_materials = DecryptionMaterials( + iv=b"initialization-vector", + encrypted_data_keys=[edk], + encryption_context_stored={"project": "alpha", "kms_cmk_id": "some-key"}, + encryption_context_from_request={"project": "alpha"}, + ) + + result = keyring.on_decrypt(dec_materials) + assert result.plaintext_data_key == b"decrypted-key-material-32-bytes!" + + def test_on_decrypt_kms_v1_rejects_mismatched_encryption_context(self): + """KmsV1 path must reject mismatched caller-provided encryption context.""" + mock_kms_client = MagicMock() + keyring = KmsKeyring(mock_kms_client, "arn:aws:kms:us-east-1:123456789012:key/test-key", + enable_legacy_wrapping_algorithms=True) + + edk = EncryptedDataKey( + key_provider_id=b"S3Keyring", + key_provider_info="kms", + encrypted_data_key=b"encrypted-key", + ) + dec_materials = DecryptionMaterials( + iv=b"initialization-vector", + encrypted_data_keys=[edk], + encryption_context_stored={"project": "alpha", "kms_cmk_id": "some-key"}, + encryption_context_from_request={"project": "beta"}, + ) + + with pytest.raises(S3EncryptionClientError, match="does not match"): + keyring.on_decrypt(dec_materials) + + # KMS should never be called when context doesn't match + mock_kms_client.decrypt.assert_not_called() diff --git a/test/test_pipelines.py b/test/test_pipelines.py index a66880e4..edd9ba8d 100644 --- a/test/test_pipelines.py +++ b/test/test_pipelines.py @@ -434,3 +434,38 @@ def test_decrypt_instruction_file_empty_metadata_raises(self): bucket="test-bucket", key="test-key", ) + + def test_decrypt_rejects_exclusive_key_collision(self): + """Metadata with both V2 and V3 EDK keys MUST be rejected.""" + import base64 + import os + + mock_cmm = Mock() + pipeline = GetEncryptedObjectPipeline( + cmm=mock_cmm, + commitment_policy=CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + ) + + fake_edk = base64.b64encode(os.urandom(32)).decode() + fake_iv = base64.b64encode(os.urandom(12)).decode() + # Metadata with both V2 (x-amz-key-v2) and V3 (x-amz-3) EDK keys + metadata = { + "x-amz-key-v2": fake_edk, + "x-amz-cek-alg": "AES/GCM/NoPadding", + "x-amz-iv": fake_iv, + "x-amz-wrap-alg": "kms+context", + "x-amz-3": fake_edk, + "x-amz-c": "115", + "x-amz-w": "12", + "x-amz-d": base64.b64encode(os.urandom(28)).decode(), + "x-amz-i": base64.b64encode(os.urandom(28)).decode(), + } + + mock_response = { + "Body": BytesIO(os.urandom(48)), + "Metadata": metadata, + "ContentLength": 48, + } + + with pytest.raises(S3EncryptionClientError, match="multiple format versions"): + pipeline.decrypt(mock_response, ".instruction", enable_delayed_authentication=False) From cf6b5931bab25d1828a8eb51c61476e881185402 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 17 Apr 2026 17:05:42 -0700 Subject: [PATCH 17/19] format --- test/test_kms_keyring.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/test/test_kms_keyring.py b/test/test_kms_keyring.py index b0881c6a..46f0a3a0 100644 --- a/test/test_kms_keyring.py +++ b/test/test_kms_keyring.py @@ -473,8 +473,11 @@ def test_on_decrypt_kms_v1_validates_encryption_context(self): mock_kms_client = MagicMock() mock_kms_client.decrypt.return_value = {"Plaintext": b"decrypted-key-material-32-bytes!"} - keyring = KmsKeyring(mock_kms_client, "arn:aws:kms:us-east-1:123456789012:key/test-key", - enable_legacy_wrapping_algorithms=True) + keyring = KmsKeyring( + mock_kms_client, + "arn:aws:kms:us-east-1:123456789012:key/test-key", + enable_legacy_wrapping_algorithms=True, + ) edk = EncryptedDataKey( key_provider_id=b"S3Keyring", @@ -494,8 +497,11 @@ def test_on_decrypt_kms_v1_validates_encryption_context(self): def test_on_decrypt_kms_v1_rejects_mismatched_encryption_context(self): """KmsV1 path must reject mismatched caller-provided encryption context.""" mock_kms_client = MagicMock() - keyring = KmsKeyring(mock_kms_client, "arn:aws:kms:us-east-1:123456789012:key/test-key", - enable_legacy_wrapping_algorithms=True) + keyring = KmsKeyring( + mock_kms_client, + "arn:aws:kms:us-east-1:123456789012:key/test-key", + enable_legacy_wrapping_algorithms=True, + ) edk = EncryptedDataKey( key_provider_id=b"S3Keyring", From 4b38e95a40dd8231e0aba9a9d7ef4d80222a312e Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 24 Apr 2026 13:15:57 -0700 Subject: [PATCH 18/19] tweak validation, add more tests --- src/s3_encryption/materials/kms_keyring.py | 27 +-- test/integration/test_i_security.py | 220 ++++++++++++--------- test/test_kms_keyring.py | 16 +- 3 files changed, 151 insertions(+), 112 deletions(-) diff --git a/src/s3_encryption/materials/kms_keyring.py b/src/s3_encryption/materials/kms_keyring.py index 39a5de28..abd6fad4 100644 --- a/src/s3_encryption/materials/kms_keyring.py +++ b/src/s3_encryption/materials/kms_keyring.py @@ -200,23 +200,16 @@ def on_decrypt(self, dec_materials, encrypted_data_keys=None): f"Enable legacy wrapping algorithms to use legacy key wrapping " f"algorithm: {edk.key_provider_info}" ) - # The KmsV1 path must also validate caller-provided encryption - # context against the stored materials description, matching the - # behavior of the kms+context path. Without this check, an attacker - # who tampers x-amz-wrap-alg from kms+context to kms can bypass - # the encryption context comparison. - encryption_context_from_request = dec_materials.encryption_context_from_request - if encryption_context_from_request: - encryption_context_stored = dec_materials.encryption_context_stored - encryption_context_stored_copy = encryption_context_stored.copy() - encryption_context_stored_copy.pop(KMS_V1_DEFAULT_KEY, None) - encryption_context_stored_copy.pop(KMS_CONTEXT_DEFAULT_KEY, None) - - if encryption_context_stored_copy != encryption_context_from_request: - raise S3EncryptionClientError( - "Provided encryption context does not match information " - "retrieved from S3" - ) + # The KmsV1 wrapping algorithm does not support caller-provided + # encryption context. If the caller provided encryption context, + # the client MUST reject the request. This prevents a downgrade + # from kms+context to kms from bypassing context validation. + if dec_materials.encryption_context_from_request: + raise S3EncryptionClientError( + "Encryption context is not supported with the KmsV1 (kms) " + "wrapping algorithm. Use kms+context wrapping algorithm to " + "use encryption context." + ) else: raise S3EncryptionClientError( f"{edk.key_provider_info} is not a valid key wrapping algorithm!" diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index f651a7da..67782c67 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -7,17 +7,24 @@ metadata to bypass encryption context validation. """ +import base64 import json import os from datetime import datetime +from unittest.mock import MagicMock import boto3 import pytest +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.padding import PKCS7 from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig -from s3_encryption.exceptions import S3EncryptionClientError +from s3_encryption.decryptor import AesCbcDecryptor +from s3_encryption.exceptions import S3EncryptionClientError, S3EncryptionClientSecurityError +from s3_encryption.materials.crypto_materials_manager import DefaultCryptoMaterialsManager from s3_encryption.materials.kms_keyring import KmsKeyring from s3_encryption.materials.materials import AlgorithmSuite, CommitmentPolicy +from s3_encryption.pipelines import GetEncryptedObjectPipeline bucket = os.environ.get("CI_S3_BUCKET", "s3ec-python-github-test-bucket") region = os.environ.get("CI_AWS_REGION", "us-west-2") @@ -93,6 +100,42 @@ def test_v3_downgrade_wrap_alg_to_kms_rejected_without_legacy(self): with pytest.raises((S3EncryptionClientError, Exception)): s3ec.get_object(Bucket=bucket, Key=key, EncryptionContext={"project": "beta"}) + def test_v3_downgrade_wrap_alg_to_kms_rejected_with_correct_context(self): + """Tampering x-amz-w from '12' to 'kms' MUST fail even with the original context. + + The V3 wrapping algorithm validation rejects "kms" as an invalid + compressed code regardless of what encryption context the caller + provides. The rejection happens before any context comparison. + """ + key = _unique_key("sec-downgrade-no-legacy-correct-ctx-") + data = b"sensitive data with context" + encryption_context = {"project": "alpha"} + + # 1. Encrypt normally with kms+context (V3 format) + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + s3ec.put_object(Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context) + + # 2. Tamper x-amz-w from '12' to 'kms' + plain_s3 = boto3.client("s3") + head = plain_s3.head_object(Bucket=bucket, Key=key) + tampered_metadata = head["Metadata"].copy() + tampered_metadata["x-amz-w"] = "kms" + + plain_s3.copy_object( + Bucket=bucket, + Key=key, + CopySource={"Bucket": bucket, "Key": key}, + Metadata=tampered_metadata, + MetadataDirective="REPLACE", + ) + + # 3. Decryption with the ORIGINAL (correct) context MUST still fail + with pytest.raises((S3EncryptionClientError, Exception)): + s3ec.get_object(Bucket=bucket, Key=key, EncryptionContext=encryption_context) + def test_v3_downgrade_wrap_alg_to_kms_rejected_with_legacy(self): """Tampering x-amz-w from '12' to 'kms' MUST still fail even with legacy enabled. @@ -241,12 +284,54 @@ class TestV2WrappingAlgorithmDowngradeAttack: canonical behavior established by the Java AmazonS3EncryptionClientV2. """ + def test_v2_downgrade_wrap_alg_to_kms_correct_context(self): + """Tampering x-amz-wrap-alg to 'kms' MUST fail even with the original correct context. + + The KmsV1 wrapping algorithm does not support encryption context. + The client MUST reject when a caller provides any encryption context + and the wrapping algorithm is 'kms', regardless of whether the + context matches the stored matdesc. + """ + key = _unique_key("sec-v2-downgrade-correct-ctx-") + data = b"sensitive v2 data" + encryption_context = {"project": "alpha"} + + # 1. Encrypt with V2 format (AES_GCM, kms+context wrapping) + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + ) + s3ec.put_object(Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context) + + # 2. Tamper x-amz-wrap-alg from 'kms+context' to 'kms' + plain_s3 = boto3.client("s3") + head = plain_s3.head_object(Bucket=bucket, Key=key) + tampered_metadata = head["Metadata"].copy() + tampered_metadata["x-amz-wrap-alg"] = "kms" + + plain_s3.copy_object( + Bucket=bucket, + Key=key, + CopySource={"Bucket": bucket, "Key": key}, + Metadata=tampered_metadata, + MetadataDirective="REPLACE", + ) + + # 3. Decrypt with legacy enabled + CORRECT original context MUST still fail + s3ec_legacy = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + enable_legacy_wrapping=True, + ) + with pytest.raises((S3EncryptionClientError, Exception)): + s3ec_legacy.get_object(Bucket=bucket, Key=key, EncryptionContext=encryption_context) + def test_v2_downgrade_wrap_alg_to_kms_mismatched_context(self): """Tampering x-amz-wrap-alg from 'kms+context' to 'kms' with wrong context. The KmsV1 wrapping algorithm does not support encryption context. - The client MUST reject when a caller provides encryption context - and the wrapping algorithm is 'kms'. + The client MUST reject when a caller provides mismatched encryption + context and the wrapping algorithm is 'kms'. """ key = _unique_key("sec-v2-downgrade-") data = b"sensitive v2 data" @@ -355,9 +440,6 @@ class TestCBCErrorIndistinguishability: def _encrypt_cbc(self, key, iv, plaintext): """Helper to encrypt with AES-CBC + PKCS7 padding.""" - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from cryptography.hazmat.primitives.padding import PKCS7 - cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) encryptor = cipher.encryptor() padder = PKCS7(128).padder() @@ -366,11 +448,6 @@ def _encrypt_cbc(self, key, iv, plaintext): def _make_cbc_decryptor(self, key, iv, content_length): """Helper to create an AesCbcDecryptor.""" - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from cryptography.hazmat.primitives.padding import PKCS7 - - from s3_encryption.decryptor import AesCbcDecryptor - cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) unpadder = PKCS7(128).unpadder() return AesCbcDecryptor(cipher.decryptor(), unpadder, content_length) @@ -381,10 +458,6 @@ def test_wrong_key_and_tampered_ciphertext_produce_same_error(self): Both cause PKCS7 unpadding to fail, but the error message and type MUST be the same so an attacker cannot distinguish between them. """ - import os - - from s3_encryption.exceptions import S3EncryptionClientSecurityError - key = os.urandom(32) iv = os.urandom(16) ciphertext = self._encrypt_cbc(key, iv, b"test data for padding oracle check") @@ -419,10 +492,6 @@ def test_truncated_ciphertext_produces_same_error(self): cryptography library. The error message MUST be identical to prevent an attacker from distinguishing truncation from padding failure. """ - import os - - from s3_encryption.exceptions import S3EncryptionClientSecurityError - key = os.urandom(32) iv = os.urandom(16) ciphertext = self._encrypt_cbc(key, iv, b"test data for truncation check") @@ -451,90 +520,65 @@ class TestInstructionFileFormatConfusion: When a V3 object uses instruction files, the instruction file metadata is merged with object metadata. If an attacker injects V2-format keys - into the instruction file, the merged metadata may match is_v2_format() - before is_v3_format(), causing the V2 decryption path to execute and - bypassing V3 key-commitment verification. - - The has_exclusive_key_collision() method exists to detect this but is - not called in any production code path. + into the instruction file (or directly into object metadata), the merged + metadata may contain keys from multiple format versions. The client + detects this via has_exclusive_key_collision() and the V2+V3 content + key coexistence check, rejecting the tampered metadata before format + dispatch. """ - def test_v2_keys_in_instruction_file_cause_format_confusion(self): - """Injecting V2 keys into a V3 instruction file MUST be detected. + def test_v2_keys_injected_into_v3_metadata_rejected(self): + """Injecting V2 keys into V3 object metadata MUST be rejected. - After merging instruction file metadata (containing V2 keys) with - object metadata (containing V3 keys), the resulting ObjectMetadata - has V2 keys plus V3 content keys. is_v2_format() matches first - because it does not check for V3 key absence, causing the V2 - decryption path to execute instead of V3. + Encrypt a V3 object, then tamper the S3 metadata to add V2 keys + alongside the existing V3 content keys. The client MUST reject + this because V2 and V3 keys should never coexist. """ - from s3_encryption.metadata import ObjectMetadata - - # Simulate V3 object metadata (stored on the S3 object). - # In V3 instruction file mode, the object metadata has content keys - # (x-amz-c, x-amz-d, x-amz-i) but NOT the EDK (x-amz-3). - v3_object_metadata = { - "x-amz-c": "115", # V3 content cipher - "x-amz-d": "dGVzdA==", # V3 key commitment - "x-amz-i": "bWVzc2FnZQ==", # V3 message ID - } + key = _unique_key("sec-v2-inject-v3-") + data = b"data for format confusion test" + encryption_context = {"project": "alpha"} - # Simulate attacker-crafted instruction file with V2 keys. - # Normally the instruction file would have x-amz-3, x-amz-w, x-amz-t - # for V3. The attacker replaces these with V2 keys. - attacker_instruction_file = { - "x-amz-key-v2": "YXR0YWNrZXJfa2V5", # V2 encrypted data key - "x-amz-cek-alg": "AES/GCM/NoPadding", # V2 content cipher - "x-amz-iv": "YXR0YWNrZXJfaXY=", # V2 IV - "x-amz-wrap-alg": "kms+context", # V2 wrapping algorithm - "x-amz-matdesc": '{"aws:x-amz-cek-alg": "AES/GCM/NoPadding"}', - } + # 1. Encrypt with V3 format + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + s3ec.put_object(Bucket=bucket, Key=key, Body=data, EncryptionContext=encryption_context) - # The forbidden-keys check only blocks V3-exclusive keys - v3_exclusive = {"x-amz-c", "x-amz-d", "x-amz-i"} - injected_keys = set(attacker_instruction_file.keys()) - assert not ( - injected_keys & v3_exclusive - ), "Test setup error: attacker keys should not overlap with V3 exclusive keys" + # 2. Tamper: inject V2 keys alongside existing V3 metadata + plain_s3 = boto3.client("s3") + head = plain_s3.head_object(Bucket=bucket, Key=key) + tampered_metadata = head["Metadata"].copy() - # Merge: instruction_metadata.update(encryption_metadata) - # This is the same merge order as pipelines.py line 297 - merged = attacker_instruction_file.copy() - merged.update(v3_object_metadata) + # Add V2 keys — the V3 keys (x-amz-c, x-amz-d, x-amz-i, x-amz-3, x-amz-w) remain + tampered_metadata["x-amz-key-v2"] = tampered_metadata.get("x-amz-3", "fake") + tampered_metadata["x-amz-cek-alg"] = "AES/GCM/NoPadding" + tampered_metadata["x-amz-iv"] = "AAAAAAAAAAAAAAAA" + tampered_metadata["x-amz-wrap-alg"] = "kms+context" - merged_metadata = ObjectMetadata.from_dict(merged) + plain_s3.copy_object( + Bucket=bucket, + Key=key, + CopySource={"Bucket": bucket, "Key": key}, + Metadata=tampered_metadata, + MetadataDirective="REPLACE", + ) - # The merged metadata has V2 keys AND V3 content keys (x-amz-c, x-amz-d, x-amz-i) - # but NOT the V3 EDK (x-amz-3), since the attacker replaced it with V2 keys. - # is_v2_format() matches because it only checks for V2 key presence + V1 absence - assert ( - merged_metadata.is_v2_format() - ), "is_v2_format() should match when V2 keys are injected alongside V3 content keys" - # is_v3_format() does NOT match because encrypted_data_key_v3 is None - # (the attacker didn't include x-amz-3) AND encrypted_data_key_v2 is not None - assert ( - not merged_metadata.is_v3_format() - ), "is_v3_format() should NOT match when V2 EDK key is present" - # V3 content keys are present but ignored — format dispatch goes to V2 - assert ( - merged_metadata.content_cipher_v3 is not None - ), "V3 content cipher should still be present in merged metadata" + # 3. Decrypt MUST fail — metadata has both V2 and V3 keys + s3ec_legacy = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT, + enable_legacy_wrapping=True, + ) + with pytest.raises((S3EncryptionClientError, Exception)): + s3ec_legacy.get_object(Bucket=bucket, Key=key, EncryptionContext=encryption_context) def test_exclusive_key_collision_detected_during_decrypt(self): """The decrypt pipeline MUST reject metadata with exclusive key collisions. When merged metadata contains both V2 and V3 exclusive keys, - the pipeline should detect the collision and raise an error - rather than silently routing to the V2 decryption path. + the pipeline detects the collision and raises an error. """ - import base64 - import os - from unittest.mock import MagicMock - - from s3_encryption.materials.crypto_materials_manager import DefaultCryptoMaterialsManager - from s3_encryption.materials.materials import CommitmentPolicy - from s3_encryption.pipelines import GetEncryptedObjectPipeline - # Create a mock CMM that would return decryption materials mock_cmm = MagicMock(spec=DefaultCryptoMaterialsManager) diff --git a/test/test_kms_keyring.py b/test/test_kms_keyring.py index 46f0a3a0..8c5b2ab2 100644 --- a/test/test_kms_keyring.py +++ b/test/test_kms_keyring.py @@ -468,11 +468,9 @@ def test_on_decrypt_fails_when_kms_fails(self): assert exc_info.value is kms_exception - def test_on_decrypt_kms_v1_validates_encryption_context(self): - """KmsV1 path must validate caller-provided encryption context against stored matdesc.""" + def test_on_decrypt_kms_v1_rejects_any_encryption_context(self): + """KmsV1 path must reject any caller-provided encryption context.""" mock_kms_client = MagicMock() - mock_kms_client.decrypt.return_value = {"Plaintext": b"decrypted-key-material-32-bytes!"} - keyring = KmsKeyring( mock_kms_client, "arn:aws:kms:us-east-1:123456789012:key/test-key", @@ -491,8 +489,10 @@ def test_on_decrypt_kms_v1_validates_encryption_context(self): encryption_context_from_request={"project": "alpha"}, ) - result = keyring.on_decrypt(dec_materials) - assert result.plaintext_data_key == b"decrypted-key-material-32-bytes!" + with pytest.raises(S3EncryptionClientError, match="not supported with the KmsV1"): + keyring.on_decrypt(dec_materials) + + mock_kms_client.decrypt.assert_not_called() def test_on_decrypt_kms_v1_rejects_mismatched_encryption_context(self): """KmsV1 path must reject mismatched caller-provided encryption context.""" @@ -515,8 +515,10 @@ def test_on_decrypt_kms_v1_rejects_mismatched_encryption_context(self): encryption_context_from_request={"project": "beta"}, ) - with pytest.raises(S3EncryptionClientError, match="does not match"): + with pytest.raises(S3EncryptionClientError, match="not supported with the KmsV1"): keyring.on_decrypt(dec_materials) + mock_kms_client.decrypt.assert_not_called() + # KMS should never be called when context doesn't match mock_kms_client.decrypt.assert_not_called() From d16986c4761f6e18cb07447cdc02c856aedd2e2b Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Fri, 24 Apr 2026 15:13:55 -0700 Subject: [PATCH 19/19] fix test server --- .../java/software/amazon/encryption/s3/RoundTripTests.java | 4 ++++ .../src/it/java/software/amazon/encryption/s3/TestUtils.java | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RoundTripTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RoundTripTests.java index e6cfae84..4763663d 100644 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RoundTripTests.java +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RoundTripTests.java @@ -332,6 +332,10 @@ public void kmsV1Legacy(TestUtils.LanguageServerTarget language) { @ParameterizedTest(name = "{displayName} for Encrypt: Java, Decrypt: {0}") @MethodSource("software.amazon.encryption.s3.TestUtils#clientsForTest") public void kmsV1LegacyWithEncCtx(TestUtils.LanguageServerTarget language) { + if (KMSV1_ENCRYPTION_CONTEXT_UNSUPPORTED.contains(language.getLanguageName())) { + throw new TestAbortedException( + "KmsV1 with encryption context not supported for: " + language.getLanguageName()); + } S3ECTestServerClient client = testServerClientFor(language); final String objectKey = appendTestSuffix("test-key-kms-v1-with-enc-ctx-" + language); final String input = "simple-test-input"; diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/TestUtils.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/TestUtils.java index d488fd2d..c1464eaf 100644 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/TestUtils.java +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/TestUtils.java @@ -98,6 +98,11 @@ public class TestUtils { public static final Set ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED = Set.of(NET_V3_TRANSITION, NET_V4); + // Languages that reject caller-provided encryption context when the + // wrapping algorithm is KmsV1 ("kms"). + public static final Set KMSV1_ENCRYPTION_CONTEXT_UNSUPPORTED = + Set.of(PYTHON_V3); + public static final Set RE_ENCRYPT_SUPPORTED = Set.of(JAVA_V3_TRANSITION, JAVA_V4);