From cc993c6dc5051d927521250929a62d3e679f3776 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Thu, 2 Apr 2026 13:08:37 -0700 Subject: [PATCH 01/11] chore: fill in gaps in testing --- .../test_i_key_commitment_policy.py | 198 ++++++++++++++++++ test/integration/test_i_s3_encryption.py | 13 ++ .../test_i_s3_encryption_instruction_file.py | 18 ++ test/test_encryption.py | 17 ++ test/test_key_commitment.py | 24 +++ test/test_pipelines.py | 124 +++++++++++ test/test_s3_encryption_client_plugin.py | 16 ++ 7 files changed, 410 insertions(+) create mode 100644 test/integration/test_i_key_commitment_policy.py diff --git a/test/integration/test_i_key_commitment_policy.py b/test/integration/test_i_key_commitment_policy.py new file mode 100644 index 00000000..900f9580 --- /dev/null +++ b/test/integration/test_i_key_commitment_policy.py @@ -0,0 +1,198 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Integration tests for key commitment policy enforcement through the front door. + +These tests verify that commitment policy behavior works end-to-end through +S3EncryptionClient.put_object / get_object, not just at the pipeline level. + +Objects are encrypted with one policy and decrypted with another to verify +cross-policy compatibility. +""" + +import os +from datetime import datetime + +import boto3 +import pytest + +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 _make_client(algorithm_suite, commitment_policy): + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, kms_key_id) + wrapped_client = boto3.client("s3") + config = S3EncryptionClientConfig( + keyring, + encryption_algorithm=algorithm_suite, + commitment_policy=commitment_policy, + ) + return S3EncryptionClient(wrapped_client, config) + + +def _unique_key(prefix): + return prefix + datetime.now().strftime("%Y-%m-%d-%H:%M:%S-%f") + + +# --------------------------------------------------------------------------- +# Non-committing (V2 GCM) objects decrypted under various policies +# --------------------------------------------------------------------------- + + +class TestNonCommittingObjectDecryptPolicies: + """Verify V2 (non-committing) objects can be decrypted under ALLOW policies + and rejected under REQUIRE_REQUIRE.""" + + PLAINTEXT = b"non-committing policy integration test" + + @pytest.fixture(autouse=True, scope="class") + def _encrypt_v2_object(self, request): + """Encrypt a single V2 object to be shared across all tests in this class.""" + key = _unique_key("kc-v2-policy-") + writer = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + ) + writer.put_object(Bucket=bucket, Key=key, Body=self.PLAINTEXT) + request.cls.s3_key = key + + def test_forbid_encrypt_allow_decrypt_decrypts_non_committing(self): + """FORBID_ENCRYPT_ALLOW_DECRYPT MUST decrypt non-committing objects.""" + reader = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + ) + response = reader.get_object(Bucket=bucket, Key=self.s3_key) + assert response["Body"].read() == self.PLAINTEXT + + def test_require_encrypt_allow_decrypt_decrypts_non_committing(self): + """REQUIRE_ENCRYPT_ALLOW_DECRYPT MUST decrypt non-committing objects.""" + reader = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT, + ) + response = reader.get_object(Bucket=bucket, Key=self.s3_key) + assert response["Body"].read() == self.PLAINTEXT + + def test_require_require_rejects_non_committing(self): + """REQUIRE_ENCRYPT_REQUIRE_DECRYPT MUST reject non-committing objects.""" + reader = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + with pytest.raises(S3EncryptionClientError, match="cannot decrypt non-key-committing"): + reader.get_object(Bucket=bucket, Key=self.s3_key) + + +# --------------------------------------------------------------------------- +# Committing (V3 KC-GCM) objects decrypted under various policies +# --------------------------------------------------------------------------- + +# Writer policies that produce committing (V3) objects +COMMITTING_WRITER_POLICIES = [ + pytest.param( + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + id="writer=REQUIRE_REQUIRE", + ), + pytest.param( + CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT, + id="writer=REQUIRE_ALLOW", + ), +] + + +@pytest.mark.parametrize("writer_policy", COMMITTING_WRITER_POLICIES) +class TestCommittingObjectDecryptPolicies: + """Verify V3 (committing) objects can be decrypted under all three policies, + regardless of which REQUIRE_ENCRYPT_* policy was used to write them.""" + + PLAINTEXT = b"committing policy integration test" + + @pytest.fixture(autouse=True) + def _encrypt_v3_object(self, writer_policy): + """Encrypt a V3 object with the parametrized writer policy.""" + key = _unique_key("kc-v3-policy-") + writer = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + writer_policy, + ) + writer.put_object(Bucket=bucket, Key=key, Body=self.PLAINTEXT) + self.s3_key = key + + def test_require_require_decrypts_committing(self): + """REQUIRE_ENCRYPT_REQUIRE_DECRYPT MUST decrypt committing objects.""" + reader = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + response = reader.get_object(Bucket=bucket, Key=self.s3_key) + assert response["Body"].read() == self.PLAINTEXT + + def test_require_encrypt_allow_decrypt_decrypts_committing(self): + """REQUIRE_ENCRYPT_ALLOW_DECRYPT MUST decrypt committing objects.""" + reader = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT, + ) + response = reader.get_object(Bucket=bucket, Key=self.s3_key) + assert response["Body"].read() == self.PLAINTEXT + + def test_forbid_encrypt_allow_decrypt_decrypts_committing(self): + """FORBID_ENCRYPT_ALLOW_DECRYPT MUST decrypt committing objects.""" + reader = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + ) + response = reader.get_object(Bucket=bucket, Key=self.s3_key) + assert response["Body"].read() == self.PLAINTEXT + + +# --------------------------------------------------------------------------- +# Encrypt-side config rejection (no S3 needed, but verifies front-door behavior) +# --------------------------------------------------------------------------- + + +class TestEncryptPolicyRejection: + """Verify that incompatible algorithm + policy combos are rejected at config time.""" + + def test_require_encrypt_allow_decrypt_rejects_non_committing(self): + """REQUIRE_ENCRYPT_ALLOW_DECRYPT MUST reject non-committing algorithm at config time.""" + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, kms_key_id) + with pytest.raises(S3EncryptionClientError): + S3EncryptionClientConfig( + keyring, + encryption_algorithm=AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT, + ) + + def test_require_encrypt_require_decrypt_rejects_non_committing(self): + """REQUIRE_ENCRYPT_REQUIRE_DECRYPT MUST reject non-committing algorithm at config time.""" + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, kms_key_id) + with pytest.raises(S3EncryptionClientError): + S3EncryptionClientConfig( + keyring, + encryption_algorithm=AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + + def test_forbid_encrypt_allow_decrypt_rejects_committing(self): + """FORBID_ENCRYPT_ALLOW_DECRYPT MUST reject committing algorithm at config time.""" + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, kms_key_id) + with pytest.raises(S3EncryptionClientError): + S3EncryptionClientConfig( + keyring, + encryption_algorithm=AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + commitment_policy=CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + ) diff --git a/test/integration/test_i_s3_encryption.py b/test/integration/test_i_s3_encryption.py index 36f826bd..59228b3c 100644 --- a/test/integration/test_i_s3_encryption.py +++ b/test/integration/test_i_s3_encryption.py @@ -136,6 +136,19 @@ def test_binary_data_roundtrip(algorithm_suite, commitment_policy): response = s3ec.get_object(Bucket=bucket, Key=key) output = response["Body"].read() assert output == data +@pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS) +def test_bytesio_body_roundtrip(algorithm_suite, commitment_policy): + """Test that a BytesIO body is encrypted and decrypted correctly.""" + from io import BytesIO + + key = _unique_key("bytesio-body-rt-") + data = b"BytesIO round trip test data" + + s3ec = _make_client(algorithm_suite, commitment_policy) + s3ec.put_object(Bucket=bucket, Key=key, Body=BytesIO(data)) + response = s3ec.get_object(Bucket=bucket, Key=key) + output = response["Body"].read() + assert output == data @pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS) diff --git a/test/integration/test_i_s3_encryption_instruction_file.py b/test/integration/test_i_s3_encryption_instruction_file.py index 6c93d832..f3619896 100644 --- a/test/integration/test_i_s3_encryption_instruction_file.py +++ b/test/integration/test_i_s3_encryption_instruction_file.py @@ -126,6 +126,24 @@ def test_decrypt_invalid_instruction_file(): s3ec.get_object(Bucket=bucket, Key=key) print(f"Error message: {exc_info.value}") +def test_decrypt_instruction_file_wrong_suffix_raises(): + """Decryption MUST fail when the instruction file suffix doesn't match the actual S3 object.""" + from s3_encryption.exceptions import S3EncryptionClientError + + key = TEST_OBJECTS["v3_instruction_file"] + + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, kms_key_id) + wrapped_client = boto3.client("s3") + config = S3EncryptionClientConfig( + keyring, + instruction_file_suffix=".wrong-suffix", + commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + s3ec = S3EncryptionClient(wrapped_client, config) + + with pytest.raises(S3EncryptionClientError, match="Failed to decrypt object"): + s3ec.get_object(Bucket=bucket, Key=key) def test_decrypt_v3_instruction_file_custom_suffix(): diff --git a/test/test_encryption.py b/test/test_encryption.py index a384afbd..0fadd266 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -135,6 +135,23 @@ def test_message_id_included_in_metadata_kc(self): _, meta = pipeline.encrypt(b"test") assert "x-amz-i" in meta + def test_bytesio_body_encrypts_successfully(self): + """Encryption MUST work when the body is a BytesIO object.""" + from io import BytesIO + + cmm, key = _mock_cmm() + pipeline = PutEncryptedObjectPipeline( + cmm, AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY + ) + plaintext = b"BytesIO body test data" + + # The plugin reads BytesIO via .read(), so the pipeline receives bytes. + # Verify the pipeline encrypts bytes from a BytesIO source correctly. + ciphertext, meta = pipeline.encrypt(plaintext) + assert ciphertext != plaintext + assert len(ciphertext) > 0 + assert "x-amz-i" in meta # V3 message ID present + # --------------------------------------------------------------------------- # ALG_AES_256_GCM_IV12_TAG16_NO_KDF diff --git a/test/test_key_commitment.py b/test/test_key_commitment.py index a0be12ce..673bf5da 100644 --- a/test/test_key_commitment.py +++ b/test/test_key_commitment.py @@ -163,3 +163,27 @@ def test_require_require_allows_committing_decrypt(self): ) result = pipeline.decrypt(response, ".instruction", enable_delayed_authentication=False) assert result.read() == plaintext + + def test_require_encrypt_allow_decrypt_allows_committing_decrypt(self): + """REQUIRE_ENCRYPT_ALLOW_DECRYPT MUST allow decryption with committing suites.""" + key = os.urandom(32) + response, dec_mats, plaintext = _v3_kc_gcm_response(key) + + pipeline = _make_pipeline( + commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT, + keyring_return=dec_mats, + ) + result = pipeline.decrypt(response, ".instruction", enable_delayed_authentication=False) + assert result.read() == plaintext + + def test_forbid_encrypt_allow_decrypt_allows_committing_decrypt(self): + """FORBID_ENCRYPT_ALLOW_DECRYPT MUST allow decryption with committing suites.""" + key = os.urandom(32) + response, dec_mats, plaintext = _v3_kc_gcm_response(key) + + pipeline = _make_pipeline( + commitment_policy=CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + keyring_return=dec_mats, + ) + result = pipeline.decrypt(response, ".instruction", enable_delayed_authentication=False) + assert result.read() == plaintext diff --git a/test/test_pipelines.py b/test/test_pipelines.py index e3d34e35..9f349140 100644 --- a/test/test_pipelines.py +++ b/test/test_pipelines.py @@ -310,3 +310,127 @@ def test_decrypt_v3_unsupported_wrap_alg(self): S3EncryptionClientError, match="AES/GCM is not a valid key wrapping algorithm" ): pipeline.decrypt(mock_response, ".instruction", enable_delayed_authentication=False) + + def test_decrypt_instruction_file_no_s3_client_raises(self): + """Instruction file fetch MUST fail when no s3_client is available.""" + # Object metadata has no EDK — triggers instruction file path + object_metadata = {} + + mock_keyring = Mock(spec=S3Keyring) + cmm = DefaultCryptoMaterialsManager(mock_keyring) + pipeline = GetEncryptedObjectPipeline( + cmm, + commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + s3_client=None, # No s3_client + ) + + mock_response = { + "Body": BytesIO(b"encrypted-data"), + "Metadata": object_metadata, + } + + with pytest.raises(S3EncryptionClientError, match="s3_client required"): + pipeline.decrypt( + mock_response, + instruction_suffix=".instruction", + enable_delayed_authentication=False, + bucket="test-bucket", + key="test-key", + ) + + def test_decrypt_instruction_file_missing_bucket_key_raises(self): + """Instruction file fetch MUST fail when Bucket or Key is missing.""" + object_metadata = {} + + mock_s3_client = Mock() + mock_keyring = Mock(spec=S3Keyring) + cmm = DefaultCryptoMaterialsManager(mock_keyring) + pipeline = GetEncryptedObjectPipeline( + cmm, + commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + s3_client=mock_s3_client, + ) + + mock_response = { + "Body": BytesIO(b"encrypted-data"), + "Metadata": object_metadata, + } + + with pytest.raises(S3EncryptionClientError, match="Bucket and key required"): + pipeline.decrypt( + mock_response, + instruction_suffix=".instruction", + enable_delayed_authentication=False, + bucket=None, + key=None, + ) + + def test_decrypt_instruction_file_s3_not_found_raises(self): + """Instruction file fetch MUST fail when the file doesn't exist in S3.""" + from botocore.exceptions import ClientError + + object_metadata = {} + + mock_s3_client = Mock() + mock_s3_client.get_object.side_effect = ClientError( + {"Error": {"Code": "NoSuchKey", "Message": "The specified key does not exist."}}, + "GetObject", + ) + # The fetch_instruction_file function checks for _s3ec_plugin_context + mock_s3_client._s3ec_plugin_context = Mock() + + mock_keyring = Mock(spec=S3Keyring) + cmm = DefaultCryptoMaterialsManager(mock_keyring) + pipeline = GetEncryptedObjectPipeline( + cmm, + commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + s3_client=mock_s3_client, + ) + + mock_response = { + "Body": BytesIO(b"encrypted-data"), + "Metadata": object_metadata, + } + + with pytest.raises(ClientError): + pipeline.decrypt( + mock_response, + instruction_suffix=".instruction", + enable_delayed_authentication=False, + bucket="test-bucket", + key="test-key", + ) + + def test_decrypt_instruction_file_empty_metadata_raises(self): + """Instruction file with no valid metadata MUST raise an error.""" + object_metadata = {} + + mock_s3_client = Mock() + # Instruction file returns empty metadata (empty body parsed to nothing) + mock_s3_client.get_object.return_value = { + "Body": BytesIO(b""), + "Metadata": {}, + } + mock_s3_client._s3ec_plugin_context = Mock() + + mock_keyring = Mock(spec=S3Keyring) + cmm = DefaultCryptoMaterialsManager(mock_keyring) + pipeline = GetEncryptedObjectPipeline( + cmm, + commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + s3_client=mock_s3_client, + ) + + mock_response = { + "Body": BytesIO(b"encrypted-data"), + "Metadata": object_metadata, + } + + with pytest.raises(S3EncryptionClientError, match="empty metadata"): + pipeline.decrypt( + mock_response, + instruction_suffix=".instruction", + enable_delayed_authentication=False, + bucket="test-bucket", + key="test-key", + ) diff --git a/test/test_s3_encryption_client_plugin.py b/test/test_s3_encryption_client_plugin.py index cbc8cd80..31d2655f 100644 --- a/test/test_s3_encryption_client_plugin.py +++ b/test/test_s3_encryption_client_plugin.py @@ -154,3 +154,19 @@ def test_missing_content_length_raises_error(self): with pytest.raises(S3EncryptionClientError, match="missing ContentLength.*Key: my-object"): plugin.on_get_object_after_call(parsed) + + def test_put_object_rejects_instruction_file_mode(self): + """put_object MUST raise when instruction-file mode is active.""" + mock_keyring = Mock(spec=S3Keyring) + config = S3EncryptionClientConfig(keyring=mock_keyring) + plugin = S3EncryptionClientPlugin(config) + + # Activate instruction file mode + plugin._context.instruction_file_mode = True + + params = {"body": b"test data", "headers": {}} + + with pytest.raises( + S3EncryptionClientError, match="not supported in put_object" + ): + plugin.on_put_object_before_call(params) From 54cab04b058f3df22dd3f1f998a8874d02d674f3 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Thu, 2 Apr 2026 13:13:59 -0700 Subject: [PATCH 02/11] linter --- test/integration/test_i_key_commitment_policy.py | 6 ++++-- test/integration/test_i_s3_encryption.py | 2 ++ test/integration/test_i_s3_encryption_instruction_file.py | 2 ++ test/test_encryption.py | 2 -- test/test_s3_encryption_client_plugin.py | 4 +--- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/test/integration/test_i_key_commitment_policy.py b/test/integration/test_i_key_commitment_policy.py index 900f9580..ba334a87 100644 --- a/test/integration/test_i_key_commitment_policy.py +++ b/test/integration/test_i_key_commitment_policy.py @@ -50,7 +50,8 @@ def _unique_key(prefix): class TestNonCommittingObjectDecryptPolicies: """Verify V2 (non-committing) objects can be decrypted under ALLOW policies - and rejected under REQUIRE_REQUIRE.""" + and rejected under REQUIRE_REQUIRE. + """ PLAINTEXT = b"non-committing policy integration test" @@ -113,7 +114,8 @@ def test_require_require_rejects_non_committing(self): @pytest.mark.parametrize("writer_policy", COMMITTING_WRITER_POLICIES) class TestCommittingObjectDecryptPolicies: """Verify V3 (committing) objects can be decrypted under all three policies, - regardless of which REQUIRE_ENCRYPT_* policy was used to write them.""" + regardless of which REQUIRE_ENCRYPT_* policy was used to write them. + """ PLAINTEXT = b"committing policy integration test" diff --git a/test/integration/test_i_s3_encryption.py b/test/integration/test_i_s3_encryption.py index 59228b3c..9aa0f6bb 100644 --- a/test/integration/test_i_s3_encryption.py +++ b/test/integration/test_i_s3_encryption.py @@ -136,6 +136,8 @@ def test_binary_data_roundtrip(algorithm_suite, commitment_policy): response = s3ec.get_object(Bucket=bucket, Key=key) output = response["Body"].read() assert output == data + + @pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS) def test_bytesio_body_roundtrip(algorithm_suite, commitment_policy): """Test that a BytesIO body is encrypted and decrypted correctly.""" diff --git a/test/integration/test_i_s3_encryption_instruction_file.py b/test/integration/test_i_s3_encryption_instruction_file.py index f3619896..a4b877fc 100644 --- a/test/integration/test_i_s3_encryption_instruction_file.py +++ b/test/integration/test_i_s3_encryption_instruction_file.py @@ -126,6 +126,8 @@ def test_decrypt_invalid_instruction_file(): s3ec.get_object(Bucket=bucket, Key=key) print(f"Error message: {exc_info.value}") + + def test_decrypt_instruction_file_wrong_suffix_raises(): """Decryption MUST fail when the instruction file suffix doesn't match the actual S3 object.""" from s3_encryption.exceptions import S3EncryptionClientError diff --git a/test/test_encryption.py b/test/test_encryption.py index 0fadd266..ede9262c 100644 --- a/test/test_encryption.py +++ b/test/test_encryption.py @@ -137,8 +137,6 @@ def test_message_id_included_in_metadata_kc(self): def test_bytesio_body_encrypts_successfully(self): """Encryption MUST work when the body is a BytesIO object.""" - from io import BytesIO - cmm, key = _mock_cmm() pipeline = PutEncryptedObjectPipeline( cmm, AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY diff --git a/test/test_s3_encryption_client_plugin.py b/test/test_s3_encryption_client_plugin.py index 31d2655f..1c930a3a 100644 --- a/test/test_s3_encryption_client_plugin.py +++ b/test/test_s3_encryption_client_plugin.py @@ -166,7 +166,5 @@ def test_put_object_rejects_instruction_file_mode(self): params = {"body": b"test data", "headers": {}} - with pytest.raises( - S3EncryptionClientError, match="not supported in put_object" - ): + with pytest.raises(S3EncryptionClientError, match="not supported in put_object"): plugin.on_put_object_before_call(params) From 3ddb5ebdec495b464691b9ae15e31972443d97e5 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Thu, 2 Apr 2026 13:37:07 -0700 Subject: [PATCH 03/11] Add test matrix and evaluation docs for review --- TEST_MATRIX.md | 214 ++++++++++++++++++++++++++++++++++++++ TEST_MATRIX_EVALUATION.md | 135 ++++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 TEST_MATRIX.md create mode 100644 TEST_MATRIX_EVALUATION.md diff --git a/TEST_MATRIX.md b/TEST_MATRIX.md new file mode 100644 index 00000000..67412987 --- /dev/null +++ b/TEST_MATRIX.md @@ -0,0 +1,214 @@ +# S3 Encryption Client for Python — End-to-End Test Matrix + +This document enumerates every customer-facing configuration option and input parameter, +then defines the matrix of combinations that must be tested end-to-end before launch. + +--- + +## 1. Use Cases + +| # | Use Case | Entry Point | +|---|----------|-------------| +| UC-1 | Encrypt and upload an object | `S3EncryptionClient.put_object(**kwargs)` | +| UC-2 | Download and decrypt an object | `S3EncryptionClient.get_object(**kwargs)` | +| UC-3 | Decrypt a legacy (V1/V2) object | `S3EncryptionClient.get_object(**kwargs)` with legacy-encrypted data | + +--- + +## 2. Configuration Options (S3EncryptionClientConfig) + +| Parameter | Type | Default | Valid Values | Notes | +|-----------|------|---------|--------------|-------| +| `keyring` | `AbstractKeyring` (required) | — | `KmsKeyring`, custom keyring | Determines key wrapping strategy | +| `encryption_algorithm` | `AlgorithmSuite` | `ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY` | See §2a | Must not be legacy; validated against commitment policy | +| `commitment_policy` | `CommitmentPolicy` | `REQUIRE_ENCRYPT_REQUIRE_DECRYPT` | See §2b | Controls key-commitment enforcement | +| `enable_legacy_unauthenticated_modes` | `bool` | `False` | `True` / `False` | Allows decryption of AES-CBC (V1) objects | +| `cmm` | `AbstractCryptoMaterialsManager` | `DefaultCryptoMaterialsManager(keyring)` | `DefaultCryptoMaterialsManager`, custom CMM | Auto-created from keyring if omitted | +| `instruction_file_suffix` | `str` | `".instruction"` | Any string | Suffix for instruction-file metadata strategy | +| `enable_delayed_authentication` | `bool` | `False` | `True` / `False` | Releases plaintext before GCM tag verification (streaming) | + +### 2a. Algorithm Suites + +| Enum Member | ID | Legacy? | Cipher | Key Commitment | +|-------------|----|---------|--------|----------------| +| `ALG_AES_256_CBC_IV16_NO_KDF` | 0x0070 | Yes | AES/CBC/PKCS5Padding | No | +| `ALG_AES_256_GCM_IV12_TAG16_NO_KDF` | 0x0072 | No | AES/GCM/NoPadding | No | +| `ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY` | 0x0073 | No | AES/GCM/HKDF/CommitKey | Yes | + +Legacy suites are rejected at config time for encryption; only allowed for decryption when `enable_legacy_unauthenticated_modes=True`. + +### 2b. Commitment Policies + +| Enum Member | Encrypt Constraint | Decrypt Constraint | +|-------------|--------------------|--------------------| +| `FORBID_ENCRYPT_ALLOW_DECRYPT` | Must NOT use committing suite | Allows any suite | +| `REQUIRE_ENCRYPT_ALLOW_DECRYPT` | Must use committing suite | Allows any suite | +| `REQUIRE_ENCRYPT_REQUIRE_DECRYPT` | Must use committing suite | Must use committing suite | + +--- + +## 3. KmsKeyring Configuration + +| Parameter | Type | Default | Valid Values | Notes | +|-----------|------|---------|--------------|-------| +| `kms_client` | boto3 KMS client (required) | — | Any `botocore.client.BaseClient` for KMS | | +| `kms_key_id` | `str` (required) | — | Any valid KMS key ARN / alias | | +| `enable_legacy_wrapping_algorithms` | `bool` | `False` | `True` / `False` | Enables decryption of V1 `"kms"` wrapped keys | + +Wrapping modes: +- `kms+context` — V2/V3 (always enabled) +- `kms` — V1 legacy (only when `enable_legacy_wrapping_algorithms=True`) + +--- + +## 4. Per-Request Input Parameters + +### 4a. put_object + +| Parameter | Type | Required | Notes | +|-----------|------|----------|-------| +| `Bucket` | `str` | Yes | S3 bucket name | +| `Key` | `str` | Yes | S3 object key | +| `Body` | `bytes`, file-like, or `None` | No | Plaintext to encrypt; empty body if omitted | +| `EncryptionContext` | `dict[str, str]` | No | Additional authenticated data passed to KMS | +| *(all other S3 PutObject params)* | various | No | Passed through to boto3 | + +### 4b. get_object + +| Parameter | Type | Required | Notes | +|-----------|------|----------|-------| +| `Bucket` | `str` | Yes | S3 bucket name | +| `Key` | `str` | Yes | S3 object key | +| `EncryptionContext` | `dict[str, str]` | No | Must match context used at encryption time | +| *(all other S3 GetObject params)* | various | No | Passed through to boto3 | + +--- + +## 5. Metadata Strategy (implicit) + +The metadata strategy is determined by the encrypted object, not by a config flag: + +| Strategy | How Detected | Relevant Config | +|----------|-------------|-----------------| +| Object metadata (header) | Encryption metadata present in S3 object metadata | — | +| Instruction file | Object metadata missing required keys | `instruction_file_suffix` on config | + +--- + +## 6. End-to-End Test Matrix + +### 6a. Encryption (put_object) — Required Combinations + +| # | encryption_algorithm | commitment_policy | keyring | EncryptionContext | Body Type | Expected | +|---|----------------------|-------------------|---------|-------------------|-----------|----------| +| E1 | GCM_HKDF_COMMIT (default) | REQUIRE_ENCRYPT_REQUIRE_DECRYPT (default) | KmsKeyring | None | `bytes` | Success | +| E2 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | KmsKeyring | `{"k":"v"}` | `bytes` | Success | +| E3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_ALLOW_DECRYPT | KmsKeyring | None | `bytes` | Success | +| E4 | GCM_IV12_NO_KDF | FORBID_ENCRYPT_ALLOW_DECRYPT | KmsKeyring | None | `bytes` | Success | +| E5 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | KmsKeyring | None | `BytesIO` | Success | +| E6 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | KmsKeyring | None | `None` (empty) | Success | +| E7 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | Custom keyring | None | `bytes` | Success | +| E8 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | Custom CMM (no keyring) | None | `bytes` | Success | + +### 6b. Decryption (get_object) — Required Combinations + +| # | Object Format | Object Algorithm | commitment_policy | enable_legacy_unauth | enable_legacy_wrapping | enable_delayed_auth | EncryptionContext | Metadata Strategy | Expected | +|---|---------------|------------------|-------------------|----------------------|------------------------|---------------------|-------------------|-------------------|----------| +| D1 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | False | None | Header | Success | +| D2 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | False | `{"k":"v"}` | Header | Success | +| D3 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | True | None | Header | Success (streaming) | +| D4 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_ALLOW_DECRYPT | False | False | False | None | Header | Success | +| D5 | V3 | GCM_HKDF_COMMIT | FORBID_ENCRYPT_ALLOW_DECRYPT | False | False | False | None | Header | Success | +| D6 | V2 | GCM_IV12_NO_KDF | REQUIRE_ENCRYPT_ALLOW_DECRYPT | False | False | False | None | Header | Success | +| D7 | V2 | GCM_IV12_NO_KDF | FORBID_ENCRYPT_ALLOW_DECRYPT | False | False | False | None | Header | Success | +| D8 | V1 | CBC | REQUIRE_ENCRYPT_ALLOW_DECRYPT | True | True | False | None | Header | Success | +| D9 | V1 | CBC | FORBID_ENCRYPT_ALLOW_DECRYPT | True | True | False | None | Header | Success | +| D10 | V2 | GCM_IV12_NO_KDF | any | False | False | False | None | Instruction file | Success | +| D11 | V3 | GCM_HKDF_COMMIT | any | False | False | False | None | Instruction file | Success | +| D12 | V2 | GCM_IV12_NO_KDF | any | False | False | False | None | Instruction file (custom suffix) | Success | +| D13 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | False | `{"k":"v"}` mismatched | Header | Success decrypt, context validation fails in keyring | + +### 6c. Round-Trip Tests (put then get) + +| # | encryption_algorithm | commitment_policy | EncryptionContext | Body Size | Notes | +|---|----------------------|-------------------|-------------------|-----------|-------| +| RT1 | GCM_HKDF_COMMIT (default) | REQUIRE_ENCRYPT_REQUIRE_DECRYPT (default) | None | Small (< 1 KB) | Happy path | +| RT2 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | `{"k":"v"}` | Small | With encryption context | +| RT3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | None | Large (> 1 MB) | Streaming / chunked | +| RT4 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | None | 0 bytes | Empty body | +| RT5 | GCM_IV12_NO_KDF | FORBID_ENCRYPT_ALLOW_DECRYPT | None | Small | Non-committing suite | +| RT6 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | None | Small | Delayed authentication enabled | + +--- + +## 7. Negative / Validation Cases (Invalid Inputs and Configurations) + +### 7a. Encryption — Invalid Configurations + +| # | encryption_algorithm | commitment_policy | Expected Error | +|---|----------------------|-------------------|----------------| +| EN1 | CBC (legacy) | any | Reject: cannot encrypt with legacy suite | +| EN2 | GCM_IV12_NO_KDF (non-committing) | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | Reject: policy requires committing suite | +| EN3 | GCM_IV12_NO_KDF (non-committing) | REQUIRE_ENCRYPT_ALLOW_DECRYPT | Reject: policy requires committing suite | +| EN4 | GCM_HKDF_COMMIT (committing) | FORBID_ENCRYPT_ALLOW_DECRYPT | Reject: policy forbids committing suite | + +### 7b. Decryption — Invalid Configurations / Inputs + +| # | Object Format | commitment_policy | enable_legacy_unauth | enable_legacy_wrapping | Expected Error | +|---|---------------|-------------------|----------------------|------------------------|----------------| +| DN1 | V1 (CBC) | any | False | any | Reject: legacy unauthenticated mode disabled | +| DN2 | V1 (CBC) | any | True | False | Reject: legacy wrapping algorithms disabled | +| DN3 | V2 (non-committing) | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | Reject: policy requires committing suite on decrypt | +| DN4 | V3 | any | False | False | Reject: mismatched EncryptionContext | +| DN5 | V3 | any | False | False | Reject: EncryptionContext contains reserved key `aws:x-amz-cek-alg` | + +### 7c. Instruction File — Invalid Inputs + +| # | Scenario | Expected Error | +|---|----------|----------------| +| IF1 | Instruction file missing from S3 | Reject: instruction file not found | +| IF2 | Instruction file contains invalid / corrupt JSON | Reject: cannot parse instruction file | +| IF3 | Instruction file suffix does not match actual suffix in S3 | Reject: instruction file not found | +| IF4 | Instruction file exists but has no body | Reject: empty or missing instruction file body | + +### 7d. General — Invalid Inputs + +| # | Scenario | Expected Error | +|---|----------|----------------| +| G1 | `Body` is an unsupported type (e.g. `int`) | Reject: unexpected body type | +| G2 | `put_object` called while in instruction-file mode | Reject: instruction file mode not supported for put_object | +| G3 | Instruction file fetch with no `s3_client` available | Reject: s3_client required | +| G4 | Instruction file fetch with missing `Bucket` or `Key` | Reject: bucket and key required | + +--- + +## 8. Streaming / Delayed Authentication + +The `enable_delayed_authentication` flag controls whether GCM plaintext is released before or after tag verification. CBC content is always streamed (no auth tag). These cases verify the streaming behavior across modes and algorithm suites. + +| # | Algorithm | Delayed Auth | Scenario | Expected | +|---|-----------|-------------|----------|----------| +| S1 | GCM (any) | False | Buffered mode withholds plaintext until GCM tag verified | Tag verified before any `.read()` returns data | +| S2 | GCM (any) | True | Delayed auth releases plaintext before tag verification | `.read()` returns data before tag is checked | +| S3 | GCM + KC-GCM | both | Both modes produce identical plaintext for same object | Byte-for-byte match | +| S4 | GCM + KC-GCM | both | Chunked / partial reads | Reassembled chunks equal original plaintext | +| S5 | GCM + KC-GCM | both | Empty body round-trip | Both modes handle 0-byte plaintext | +| S6 | GCM + KC-GCM | True | Large object (≥ 1 MB) streaming | Chunked delayed-auth reads produce correct plaintext | +| S7 | CBC | N/A | CBC always streams (no buffered mode) | Decryption succeeds regardless of flag | +| S8 | GCM (any) | False | Tampered ciphertext detected | Buffered mode raises error, no plaintext released | +| S9 | GCM (any) | True | Tampered tag detected | Delayed auth raises error after final read | + +--- + +## 9. Cross-Cutting Concerns + +These should be verified across multiple matrix entries: + +| Concern | What to Verify | +|---------|----------------| +| Thread safety | Concurrent put_object / get_object calls share no state | +| Custom CMM | Encryption and decryption work when providing a CMM instead of a keyring | +| Custom keyring | A user-implemented `AbstractKeyring` subclass works end-to-end | +| Multi-region KMS keys | Encrypt in one region, decrypt in another | +| Error propagation | `S3EncryptionClientError` and `S3EncryptionClientSecurityError` surface correctly | +| Instruction file edge cases | Missing instruction file, corrupt instruction file, wrong suffix | diff --git a/TEST_MATRIX_EVALUATION.md b/TEST_MATRIX_EVALUATION.md new file mode 100644 index 00000000..4896ebf3 --- /dev/null +++ b/TEST_MATRIX_EVALUATION.md @@ -0,0 +1,135 @@ +# S3 Encryption Client — Test Matrix Evaluation + +Maps each test case from `TEST_MATRIX.md` to existing tests in the codebase. +"TODO" means no existing test was found for that case. + +--- + +## Encryption (put_object) — Positive Cases + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| E1 | Default config, KmsKeyring, bytes body | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_simple_roundtrip_ascii_string[KC_GCM]` | +| E2 | Default config + EncryptionContext | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_encryption_context_roundtrip[KC_GCM]` | +| E3 | REQUIRE_ENCRYPT_ALLOW_DECRYPT + committing suite | ✅ Covered | Both | Unit: `test/test_default_algorithm_commitment.py::test_default_encryption_decryptable_with_require_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestCommittingObjectDecryptPolicies[writer=REQUIRE_ALLOW]` | +| E4 | GCM_IV12_NO_KDF + FORBID_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_simple_roundtrip_ascii_string[AES_GCM]` | +| E5 | BytesIO body | 🔄 In progress | Both | Unit: `test/test_encryption.py::TestContentEncryption::test_bytesio_body_encrypts_successfully`; Integration: `test/integration/test_i_s3_encryption.py::test_bytesio_body_roundtrip` | +| E6 | None / empty body | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_no_body_roundtrip` | +| E7 | Custom keyring | ❌ TODO | — | No integration test uses a user-implemented `AbstractKeyring` subclass | +| E8 | Custom CMM (no keyring) | ❌ TODO | — | No integration test provides a custom CMM directly | + +--- + +## Decryption (get_object) — Positive Cases + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| D1 | V3 GCM_HKDF_COMMIT, REQUIRE_REQUIRE, header | ✅ Covered | Both | Unit: `test/test_key_commitment.py::test_require_require_allows_committing_decrypt`, `test/test_default_algorithm_commitment.py::test_default_encryption_decryptable_with_require_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestCommittingObjectDecryptPolicies::test_require_require_decrypts_committing` | +| D2 | V3 + EncryptionContext | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_encryption_context_roundtrip[KC_GCM]` | +| D3 | V3 + delayed auth | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_delayed_authentication_mode[delayed-auth]` and `test/integration/test_i_s3_encryption_streaming.py::test_delayed_auth_roundtrip[KC_GCM]` | +| D4 | V3, REQUIRE_ENCRYPT_ALLOW_DECRYPT | 🔄 In progress | Both | Unit: `test/test_key_commitment.py::TestCommitmentPolicy::test_require_encrypt_allow_decrypt_allows_committing_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestCommittingObjectDecryptPolicies::test_require_encrypt_allow_decrypt_decrypts_committing` | +| D5 | V3, FORBID_ENCRYPT_ALLOW_DECRYPT | 🔄 In progress | Both | Unit: `test/test_key_commitment.py::TestCommitmentPolicy::test_forbid_encrypt_allow_decrypt_allows_committing_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestCommittingObjectDecryptPolicies::test_forbid_encrypt_allow_decrypt_decrypts_committing` | +| D6 | V2 GCM, REQUIRE_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment.py::test_require_encrypt_allow_decrypt_allows_non_committing_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestNonCommittingObjectDecryptPolicies::test_require_encrypt_allow_decrypt_decrypts_non_committing` | +| D7 | V2 GCM, FORBID_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment.py::test_forbid_encrypt_allows_non_committing_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestNonCommittingObjectDecryptPolicies::test_forbid_encrypt_allow_decrypt_decrypts_non_committing` | +| D8 | V1 CBC, legacy enabled, legacy wrapping enabled | ✅ Covered | Unit | `test/test_decryption.py::TestCBCDecryption::test_cbc_decryption_succeeds_when_legacy_enabled` | +| D9 | V1 CBC, FORBID_ENCRYPT_ALLOW_DECRYPT + legacy | ✅ Covered | Unit | Same as D8 (uses FORBID_ENCRYPT_ALLOW_DECRYPT) | +| D10 | V2 GCM via instruction file | ✅ Covered | Both | Unit: `test/test_pipelines.py::test_decrypt_v2_from_instruction_file`; Integration: `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_v2_instruction_file` | +| D11 | V3 via instruction file | ✅ Covered | Both | Unit: `test/test_pipelines.py::test_decrypt_v3_from_instruction_file`; Integration: `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_v3_instruction_file` | +| D12 | V2 instruction file, custom suffix | ✅ Covered | Both | Unit: `test/test_pipelines.py::test_decrypt_with_custom_instruction_file_suffix`; Integration: `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_v2_instruction_file_custom_suffix` | +| D13 | V3 + mismatched EncryptionContext | ✅ Covered | Both | Unit: `test/test_kms_keyring.py::TestKmsKeyringOnDecrypt::test_on_decrypt_fails_with_mismatched_encryption_context`; Integration: `test/integration/test_i_s3_encryption.py::test_encryption_context_mismatch` | + +--- + +## Round-Trip Tests + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| RT1 | Default config, small body | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_simple_roundtrip_ascii_string[KC_GCM]` | +| RT2 | Default config + EncryptionContext | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_encryption_context_roundtrip[KC_GCM]` | +| RT3 | Large body (> 1 MB) | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_delayed_auth_large_object` (1 MB) and `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_large_v2_instruction_file_delayed_auth` (50 MB) | +| RT4 | Empty body (0 bytes) | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_empty_body_roundtrip` and `test/integration/test_i_s3_encryption.py::test_no_body_roundtrip` | +| RT5 | GCM_IV12_NO_KDF + FORBID | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_simple_roundtrip_ascii_string[AES_GCM]` | +| RT6 | Delayed authentication | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_delayed_auth_roundtrip` and `test/integration/test_i_s3_encryption.py::test_delayed_authentication_mode` | + +--- + +## Negative / Validation Cases — Encryption + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| EN1 | Reject legacy suite for encryption | ✅ Covered | Unit | `test/test_key_commitment_encrypt.py` — legacy CBC rejected at config time | +| EN2 | GCM_IV12 + REQUIRE_ENCRYPT_REQUIRE_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment_encrypt.py::TestRequireEncryptRejectsNonCommitting::test_require_encrypt_require_decrypt_rejects_non_committing_gcm`; Integration: `test/integration/test_i_key_commitment_policy.py::TestEncryptPolicyRejection::test_require_encrypt_require_decrypt_rejects_non_committing` | +| EN3 | GCM_IV12 + REQUIRE_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment_encrypt.py::TestRequireEncryptRejectsNonCommitting::test_require_encrypt_allow_decrypt_rejects_non_committing_gcm`; Integration: `test/integration/test_i_key_commitment_policy.py::TestEncryptPolicyRejection::test_require_encrypt_allow_decrypt_rejects_non_committing` | +| EN4 | GCM_HKDF_COMMIT + FORBID_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment_encrypt.py::TestForbidEncryptRejectsCommitting::test_forbid_encrypt_allow_decrypt_rejects_committing_gcm`; Integration: `test/integration/test_i_key_commitment_policy.py::TestEncryptPolicyRejection::test_forbid_encrypt_allow_decrypt_rejects_committing` | + +--- + +## Negative / Validation Cases — Decryption + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| DN1 | V1 CBC rejected when legacy disabled | ✅ Covered | Unit | `test/test_decryption.py::TestCBCDecryption::test_cbc_object_rejected_when_legacy_disabled` and `test/test_decryption.py::TestLegacyDecryption::test_legacy_cbc_rejected_by_default` | +| DN2 | V1 CBC, legacy enabled but legacy wrapping disabled | ✅ Covered | Unit | `test/test_kms_keyring.py::TestKmsKeyringOnDecrypt::test_on_decrypt_rejects_kms_v1_when_legacy_disabled` | +| DN3 | V2 non-committing + REQUIRE_REQUIRE | ✅ Covered | Both | Unit: `test/test_key_commitment.py::TestCommitmentPolicy::test_require_require_rejects_non_committing_decrypt` and `test/test_decryption.py::TestKeyCommitmentPolicy::test_require_decrypt_rejects_non_committing_suite`; Integration: `test/integration/test_i_key_commitment_policy.py::TestNonCommittingObjectDecryptPolicies::test_require_require_rejects_non_committing` | +| DN4 | Mismatched EncryptionContext | ✅ Covered | Both | Unit: `test/test_kms_keyring.py::TestKmsKeyringOnDecrypt::test_on_decrypt_fails_with_mismatched_encryption_context`; Integration: `test/integration/test_i_s3_encryption.py::test_encryption_context_mismatch` | +| DN5 | Reserved key in EncryptionContext | ✅ Covered | Unit | `test/test_kms_keyring.py::TestKmsKeyringOnDecrypt::test_on_decrypt_rejects_reserved_key_in_request_context` | + +--- + +## Negative / Validation Cases — Instruction File + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| IF1 | Instruction file missing from S3 | 🔄 In progress | Unit | `test/test_pipelines.py::TestGetEncryptedObjectPipelineInstructionFile::test_decrypt_instruction_file_s3_not_found_raises` | +| IF2 | Instruction file contains invalid JSON | ✅ Covered | Both | Unit: `test/test_s3_encryption_client_plugin.py::test_instruction_file_mode_invalid_json_raises_error`; Integration: `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_invalid_instruction_file` | +| IF3 | Instruction file suffix mismatch | 🔄 In progress | Integration | `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_instruction_file_wrong_suffix_raises` | +| IF4 | Instruction file exists but has no body | 🔄 In progress | Unit | `test/test_pipelines.py::TestGetEncryptedObjectPipelineInstructionFile::test_decrypt_instruction_file_empty_metadata_raises` | + +--- + +## Negative / Validation Cases — General + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| G1 | Unsupported Body type | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_invalid_body_types` | +| G2 | put_object in instruction-file mode | 🔄 In progress | Unit | `test/test_s3_encryption_client_plugin.py::TestS3EncryptionClientPlugin::test_put_object_rejects_instruction_file_mode` | +| G3 | Instruction file fetch with no s3_client | 🔄 In progress | Unit | `test/test_pipelines.py::TestGetEncryptedObjectPipelineInstructionFile::test_decrypt_instruction_file_no_s3_client_raises` | +| G4 | Instruction file fetch with missing Bucket/Key | 🔄 In progress | Unit | `test/test_pipelines.py::TestGetEncryptedObjectPipelineInstructionFile::test_decrypt_instruction_file_missing_bucket_key_raises` | + +--- + +## Streaming / Delayed Authentication + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| S1 | Buffered withholds plaintext until tag verified | ✅ Covered | Unit | `test/test_stream.py::TestBufferedWithholdsUntilVerification::test_buffered_verifies_tag_before_releasing_any_plaintext` | +| S2 | Delayed auth releases plaintext before tag verification | ✅ Covered | Unit | `test/test_stream.py::TestDelayedAuthReleasesBeforeVerification::test_delayed_auth_releases_plaintext_before_tag_verification` | +| S3 | Both modes produce identical plaintext | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_buffered_and_delayed_produce_same_plaintext` | +| S4 | Chunked / partial reads | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_buffered_partial_reads` and `test_delayed_auth_chunked_reads` | +| S5 | Empty body round-trip both modes | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_empty_body_roundtrip` (parametrized buffered + delayed-auth) | +| S6 | Large object delayed-auth streaming | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_delayed_auth_large_object` (1 MB) | +| S7 | CBC always streams regardless of flag | ✅ Covered | Unit | `test/test_stream.py::TestDelayedAuthCBCDecryption` (full suite of CBC streaming tests) | +| S8 | Tampered ciphertext detected (buffered) | ✅ Covered | Unit | `test/test_stream.py::TestBufferedDecryptingStream::test_tampered_ciphertext_raises_error` | +| S9 | Tampered tag detected (delayed auth) | ✅ Covered | Unit | `test/test_stream.py::TestDelayedAuthGCMDecryption::test_tampered_tag_raises_error` | + +--- + +## Cross-Cutting Concerns + +| Concern | Status | Type | Test Location | +|---------|--------|------|---------------| +| Thread safety | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_multithreaded.py` (3 tests: isolation, rapid switching, mixed) | +| Custom CMM | ❌ TODO | — | No end-to-end test with a user-provided CMM | +| Custom keyring | ❌ TODO | — | No end-to-end test with a user-implemented `AbstractKeyring` | +| Multi-region KMS keys | ❌ TODO | — | No test for cross-region encrypt/decrypt | +| Error propagation | ✅ Covered | Unit | `test/test_exceptions.py` (both error classes, inheritance from `BotoCoreError`) | +| Instruction file edge cases | 🔄 In progress | Both | Unit: invalid JSON, invalid keys, missing file, empty body; Integration: invalid instruction file; suffix mismatch is in progress| + +--- + +## Summary + +- Total test cases: 49 (E1–E8, D1–D13, RT1–RT6, EN1–EN4, DN1–DN5, IF1–IF4, G1–G4, S1–S9) +- Covered: 37 +- In progress (this PR): 9 (E5, D4, D5, IF1, IF3, IF4, G2, G3, G4) +- TODO: 2 (E7, E8) From fbc95164734af89e4588075870b1ffe9fc3996b0 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Thu, 2 Apr 2026 13:37:07 -0700 Subject: [PATCH 04/11] Add test matrix and evaluation docs for review --- TEST_MATRIX.md | 214 ++++++++++++++++++++++++++++++++++++++ TEST_MATRIX_EVALUATION.md | 135 ++++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 TEST_MATRIX.md create mode 100644 TEST_MATRIX_EVALUATION.md diff --git a/TEST_MATRIX.md b/TEST_MATRIX.md new file mode 100644 index 00000000..67412987 --- /dev/null +++ b/TEST_MATRIX.md @@ -0,0 +1,214 @@ +# S3 Encryption Client for Python — End-to-End Test Matrix + +This document enumerates every customer-facing configuration option and input parameter, +then defines the matrix of combinations that must be tested end-to-end before launch. + +--- + +## 1. Use Cases + +| # | Use Case | Entry Point | +|---|----------|-------------| +| UC-1 | Encrypt and upload an object | `S3EncryptionClient.put_object(**kwargs)` | +| UC-2 | Download and decrypt an object | `S3EncryptionClient.get_object(**kwargs)` | +| UC-3 | Decrypt a legacy (V1/V2) object | `S3EncryptionClient.get_object(**kwargs)` with legacy-encrypted data | + +--- + +## 2. Configuration Options (S3EncryptionClientConfig) + +| Parameter | Type | Default | Valid Values | Notes | +|-----------|------|---------|--------------|-------| +| `keyring` | `AbstractKeyring` (required) | — | `KmsKeyring`, custom keyring | Determines key wrapping strategy | +| `encryption_algorithm` | `AlgorithmSuite` | `ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY` | See §2a | Must not be legacy; validated against commitment policy | +| `commitment_policy` | `CommitmentPolicy` | `REQUIRE_ENCRYPT_REQUIRE_DECRYPT` | See §2b | Controls key-commitment enforcement | +| `enable_legacy_unauthenticated_modes` | `bool` | `False` | `True` / `False` | Allows decryption of AES-CBC (V1) objects | +| `cmm` | `AbstractCryptoMaterialsManager` | `DefaultCryptoMaterialsManager(keyring)` | `DefaultCryptoMaterialsManager`, custom CMM | Auto-created from keyring if omitted | +| `instruction_file_suffix` | `str` | `".instruction"` | Any string | Suffix for instruction-file metadata strategy | +| `enable_delayed_authentication` | `bool` | `False` | `True` / `False` | Releases plaintext before GCM tag verification (streaming) | + +### 2a. Algorithm Suites + +| Enum Member | ID | Legacy? | Cipher | Key Commitment | +|-------------|----|---------|--------|----------------| +| `ALG_AES_256_CBC_IV16_NO_KDF` | 0x0070 | Yes | AES/CBC/PKCS5Padding | No | +| `ALG_AES_256_GCM_IV12_TAG16_NO_KDF` | 0x0072 | No | AES/GCM/NoPadding | No | +| `ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY` | 0x0073 | No | AES/GCM/HKDF/CommitKey | Yes | + +Legacy suites are rejected at config time for encryption; only allowed for decryption when `enable_legacy_unauthenticated_modes=True`. + +### 2b. Commitment Policies + +| Enum Member | Encrypt Constraint | Decrypt Constraint | +|-------------|--------------------|--------------------| +| `FORBID_ENCRYPT_ALLOW_DECRYPT` | Must NOT use committing suite | Allows any suite | +| `REQUIRE_ENCRYPT_ALLOW_DECRYPT` | Must use committing suite | Allows any suite | +| `REQUIRE_ENCRYPT_REQUIRE_DECRYPT` | Must use committing suite | Must use committing suite | + +--- + +## 3. KmsKeyring Configuration + +| Parameter | Type | Default | Valid Values | Notes | +|-----------|------|---------|--------------|-------| +| `kms_client` | boto3 KMS client (required) | — | Any `botocore.client.BaseClient` for KMS | | +| `kms_key_id` | `str` (required) | — | Any valid KMS key ARN / alias | | +| `enable_legacy_wrapping_algorithms` | `bool` | `False` | `True` / `False` | Enables decryption of V1 `"kms"` wrapped keys | + +Wrapping modes: +- `kms+context` — V2/V3 (always enabled) +- `kms` — V1 legacy (only when `enable_legacy_wrapping_algorithms=True`) + +--- + +## 4. Per-Request Input Parameters + +### 4a. put_object + +| Parameter | Type | Required | Notes | +|-----------|------|----------|-------| +| `Bucket` | `str` | Yes | S3 bucket name | +| `Key` | `str` | Yes | S3 object key | +| `Body` | `bytes`, file-like, or `None` | No | Plaintext to encrypt; empty body if omitted | +| `EncryptionContext` | `dict[str, str]` | No | Additional authenticated data passed to KMS | +| *(all other S3 PutObject params)* | various | No | Passed through to boto3 | + +### 4b. get_object + +| Parameter | Type | Required | Notes | +|-----------|------|----------|-------| +| `Bucket` | `str` | Yes | S3 bucket name | +| `Key` | `str` | Yes | S3 object key | +| `EncryptionContext` | `dict[str, str]` | No | Must match context used at encryption time | +| *(all other S3 GetObject params)* | various | No | Passed through to boto3 | + +--- + +## 5. Metadata Strategy (implicit) + +The metadata strategy is determined by the encrypted object, not by a config flag: + +| Strategy | How Detected | Relevant Config | +|----------|-------------|-----------------| +| Object metadata (header) | Encryption metadata present in S3 object metadata | — | +| Instruction file | Object metadata missing required keys | `instruction_file_suffix` on config | + +--- + +## 6. End-to-End Test Matrix + +### 6a. Encryption (put_object) — Required Combinations + +| # | encryption_algorithm | commitment_policy | keyring | EncryptionContext | Body Type | Expected | +|---|----------------------|-------------------|---------|-------------------|-----------|----------| +| E1 | GCM_HKDF_COMMIT (default) | REQUIRE_ENCRYPT_REQUIRE_DECRYPT (default) | KmsKeyring | None | `bytes` | Success | +| E2 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | KmsKeyring | `{"k":"v"}` | `bytes` | Success | +| E3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_ALLOW_DECRYPT | KmsKeyring | None | `bytes` | Success | +| E4 | GCM_IV12_NO_KDF | FORBID_ENCRYPT_ALLOW_DECRYPT | KmsKeyring | None | `bytes` | Success | +| E5 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | KmsKeyring | None | `BytesIO` | Success | +| E6 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | KmsKeyring | None | `None` (empty) | Success | +| E7 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | Custom keyring | None | `bytes` | Success | +| E8 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | Custom CMM (no keyring) | None | `bytes` | Success | + +### 6b. Decryption (get_object) — Required Combinations + +| # | Object Format | Object Algorithm | commitment_policy | enable_legacy_unauth | enable_legacy_wrapping | enable_delayed_auth | EncryptionContext | Metadata Strategy | Expected | +|---|---------------|------------------|-------------------|----------------------|------------------------|---------------------|-------------------|-------------------|----------| +| D1 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | False | None | Header | Success | +| D2 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | False | `{"k":"v"}` | Header | Success | +| D3 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | True | None | Header | Success (streaming) | +| D4 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_ALLOW_DECRYPT | False | False | False | None | Header | Success | +| D5 | V3 | GCM_HKDF_COMMIT | FORBID_ENCRYPT_ALLOW_DECRYPT | False | False | False | None | Header | Success | +| D6 | V2 | GCM_IV12_NO_KDF | REQUIRE_ENCRYPT_ALLOW_DECRYPT | False | False | False | None | Header | Success | +| D7 | V2 | GCM_IV12_NO_KDF | FORBID_ENCRYPT_ALLOW_DECRYPT | False | False | False | None | Header | Success | +| D8 | V1 | CBC | REQUIRE_ENCRYPT_ALLOW_DECRYPT | True | True | False | None | Header | Success | +| D9 | V1 | CBC | FORBID_ENCRYPT_ALLOW_DECRYPT | True | True | False | None | Header | Success | +| D10 | V2 | GCM_IV12_NO_KDF | any | False | False | False | None | Instruction file | Success | +| D11 | V3 | GCM_HKDF_COMMIT | any | False | False | False | None | Instruction file | Success | +| D12 | V2 | GCM_IV12_NO_KDF | any | False | False | False | None | Instruction file (custom suffix) | Success | +| D13 | V3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | False | `{"k":"v"}` mismatched | Header | Success decrypt, context validation fails in keyring | + +### 6c. Round-Trip Tests (put then get) + +| # | encryption_algorithm | commitment_policy | EncryptionContext | Body Size | Notes | +|---|----------------------|-------------------|-------------------|-----------|-------| +| RT1 | GCM_HKDF_COMMIT (default) | REQUIRE_ENCRYPT_REQUIRE_DECRYPT (default) | None | Small (< 1 KB) | Happy path | +| RT2 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | `{"k":"v"}` | Small | With encryption context | +| RT3 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | None | Large (> 1 MB) | Streaming / chunked | +| RT4 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | None | 0 bytes | Empty body | +| RT5 | GCM_IV12_NO_KDF | FORBID_ENCRYPT_ALLOW_DECRYPT | None | Small | Non-committing suite | +| RT6 | GCM_HKDF_COMMIT | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | None | Small | Delayed authentication enabled | + +--- + +## 7. Negative / Validation Cases (Invalid Inputs and Configurations) + +### 7a. Encryption — Invalid Configurations + +| # | encryption_algorithm | commitment_policy | Expected Error | +|---|----------------------|-------------------|----------------| +| EN1 | CBC (legacy) | any | Reject: cannot encrypt with legacy suite | +| EN2 | GCM_IV12_NO_KDF (non-committing) | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | Reject: policy requires committing suite | +| EN3 | GCM_IV12_NO_KDF (non-committing) | REQUIRE_ENCRYPT_ALLOW_DECRYPT | Reject: policy requires committing suite | +| EN4 | GCM_HKDF_COMMIT (committing) | FORBID_ENCRYPT_ALLOW_DECRYPT | Reject: policy forbids committing suite | + +### 7b. Decryption — Invalid Configurations / Inputs + +| # | Object Format | commitment_policy | enable_legacy_unauth | enable_legacy_wrapping | Expected Error | +|---|---------------|-------------------|----------------------|------------------------|----------------| +| DN1 | V1 (CBC) | any | False | any | Reject: legacy unauthenticated mode disabled | +| DN2 | V1 (CBC) | any | True | False | Reject: legacy wrapping algorithms disabled | +| DN3 | V2 (non-committing) | REQUIRE_ENCRYPT_REQUIRE_DECRYPT | False | False | Reject: policy requires committing suite on decrypt | +| DN4 | V3 | any | False | False | Reject: mismatched EncryptionContext | +| DN5 | V3 | any | False | False | Reject: EncryptionContext contains reserved key `aws:x-amz-cek-alg` | + +### 7c. Instruction File — Invalid Inputs + +| # | Scenario | Expected Error | +|---|----------|----------------| +| IF1 | Instruction file missing from S3 | Reject: instruction file not found | +| IF2 | Instruction file contains invalid / corrupt JSON | Reject: cannot parse instruction file | +| IF3 | Instruction file suffix does not match actual suffix in S3 | Reject: instruction file not found | +| IF4 | Instruction file exists but has no body | Reject: empty or missing instruction file body | + +### 7d. General — Invalid Inputs + +| # | Scenario | Expected Error | +|---|----------|----------------| +| G1 | `Body` is an unsupported type (e.g. `int`) | Reject: unexpected body type | +| G2 | `put_object` called while in instruction-file mode | Reject: instruction file mode not supported for put_object | +| G3 | Instruction file fetch with no `s3_client` available | Reject: s3_client required | +| G4 | Instruction file fetch with missing `Bucket` or `Key` | Reject: bucket and key required | + +--- + +## 8. Streaming / Delayed Authentication + +The `enable_delayed_authentication` flag controls whether GCM plaintext is released before or after tag verification. CBC content is always streamed (no auth tag). These cases verify the streaming behavior across modes and algorithm suites. + +| # | Algorithm | Delayed Auth | Scenario | Expected | +|---|-----------|-------------|----------|----------| +| S1 | GCM (any) | False | Buffered mode withholds plaintext until GCM tag verified | Tag verified before any `.read()` returns data | +| S2 | GCM (any) | True | Delayed auth releases plaintext before tag verification | `.read()` returns data before tag is checked | +| S3 | GCM + KC-GCM | both | Both modes produce identical plaintext for same object | Byte-for-byte match | +| S4 | GCM + KC-GCM | both | Chunked / partial reads | Reassembled chunks equal original plaintext | +| S5 | GCM + KC-GCM | both | Empty body round-trip | Both modes handle 0-byte plaintext | +| S6 | GCM + KC-GCM | True | Large object (≥ 1 MB) streaming | Chunked delayed-auth reads produce correct plaintext | +| S7 | CBC | N/A | CBC always streams (no buffered mode) | Decryption succeeds regardless of flag | +| S8 | GCM (any) | False | Tampered ciphertext detected | Buffered mode raises error, no plaintext released | +| S9 | GCM (any) | True | Tampered tag detected | Delayed auth raises error after final read | + +--- + +## 9. Cross-Cutting Concerns + +These should be verified across multiple matrix entries: + +| Concern | What to Verify | +|---------|----------------| +| Thread safety | Concurrent put_object / get_object calls share no state | +| Custom CMM | Encryption and decryption work when providing a CMM instead of a keyring | +| Custom keyring | A user-implemented `AbstractKeyring` subclass works end-to-end | +| Multi-region KMS keys | Encrypt in one region, decrypt in another | +| Error propagation | `S3EncryptionClientError` and `S3EncryptionClientSecurityError` surface correctly | +| Instruction file edge cases | Missing instruction file, corrupt instruction file, wrong suffix | diff --git a/TEST_MATRIX_EVALUATION.md b/TEST_MATRIX_EVALUATION.md new file mode 100644 index 00000000..3f2756c2 --- /dev/null +++ b/TEST_MATRIX_EVALUATION.md @@ -0,0 +1,135 @@ +# S3 Encryption Client — Test Matrix Evaluation + +Maps each test case from `TEST_MATRIX.md` to existing tests in the codebase. +"TODO" means no existing test was found for that case. + +--- + +## Encryption (put_object) — Positive Cases + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| E1 | Default config, KmsKeyring, bytes body | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_simple_roundtrip_ascii_string[KC_GCM]` | +| E2 | Default config + EncryptionContext | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_encryption_context_roundtrip[KC_GCM]` | +| E3 | REQUIRE_ENCRYPT_ALLOW_DECRYPT + committing suite | ✅ Covered | Both | Unit: `test/test_default_algorithm_commitment.py::test_default_encryption_decryptable_with_require_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestCommittingObjectDecryptPolicies[writer=REQUIRE_ALLOW]` | +| E4 | GCM_IV12_NO_KDF + FORBID_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_simple_roundtrip_ascii_string[AES_GCM]` | +| E5 | BytesIO body | 🔄 In progress | Both | Unit: `test/test_encryption.py::TestContentEncryption::test_bytesio_body_encrypts_successfully`; Integration: `test/integration/test_i_s3_encryption.py::test_bytesio_body_roundtrip` | +| E6 | None / empty body | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_no_body_roundtrip` | +| E7 | Custom keyring | ❌ TODO | — | No integration test uses a user-implemented `AbstractKeyring` subclass | +| E8 | Custom CMM (no keyring) | ❌ TODO | — | No integration test provides a custom CMM directly | + +--- + +## Decryption (get_object) — Positive Cases + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| D1 | V3 GCM_HKDF_COMMIT, REQUIRE_REQUIRE, header | ✅ Covered | Both | Unit: `test/test_key_commitment.py::test_require_require_allows_committing_decrypt`, `test/test_default_algorithm_commitment.py::test_default_encryption_decryptable_with_require_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestCommittingObjectDecryptPolicies::test_require_require_decrypts_committing` | +| D2 | V3 + EncryptionContext | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_encryption_context_roundtrip[KC_GCM]` | +| D3 | V3 + delayed auth | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_delayed_authentication_mode[delayed-auth]` and `test/integration/test_i_s3_encryption_streaming.py::test_delayed_auth_roundtrip[KC_GCM]` | +| D4 | V3, REQUIRE_ENCRYPT_ALLOW_DECRYPT | 🔄 In progress | Both | Unit: `test/test_key_commitment.py::TestCommitmentPolicy::test_require_encrypt_allow_decrypt_allows_committing_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestCommittingObjectDecryptPolicies::test_require_encrypt_allow_decrypt_decrypts_committing` | +| D5 | V3, FORBID_ENCRYPT_ALLOW_DECRYPT | 🔄 In progress | Both | Unit: `test/test_key_commitment.py::TestCommitmentPolicy::test_forbid_encrypt_allow_decrypt_allows_committing_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestCommittingObjectDecryptPolicies::test_forbid_encrypt_allow_decrypt_decrypts_committing` | +| D6 | V2 GCM, REQUIRE_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment.py::test_require_encrypt_allow_decrypt_allows_non_committing_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestNonCommittingObjectDecryptPolicies::test_require_encrypt_allow_decrypt_decrypts_non_committing` | +| D7 | V2 GCM, FORBID_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment.py::test_forbid_encrypt_allows_non_committing_decrypt`; Integration: `test/integration/test_i_key_commitment_policy.py::TestNonCommittingObjectDecryptPolicies::test_forbid_encrypt_allow_decrypt_decrypts_non_committing` | +| D8 | V1 CBC, legacy enabled, legacy wrapping enabled | ✅ Covered | Unit | `test/test_decryption.py::TestCBCDecryption::test_cbc_decryption_succeeds_when_legacy_enabled` | +| D9 | V1 CBC, FORBID_ENCRYPT_ALLOW_DECRYPT + legacy | ✅ Covered | Unit | Same as D8 (uses FORBID_ENCRYPT_ALLOW_DECRYPT) | +| D10 | V2 GCM via instruction file | ✅ Covered | Both | Unit: `test/test_pipelines.py::test_decrypt_v2_from_instruction_file`; Integration: `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_v2_instruction_file` | +| D11 | V3 via instruction file | ✅ Covered | Both | Unit: `test/test_pipelines.py::test_decrypt_v3_from_instruction_file`; Integration: `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_v3_instruction_file` | +| D12 | V2 instruction file, custom suffix | ✅ Covered | Both | Unit: `test/test_pipelines.py::test_decrypt_with_custom_instruction_file_suffix`; Integration: `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_v2_instruction_file_custom_suffix` | +| D13 | V3 + mismatched EncryptionContext | ✅ Covered | Both | Unit: `test/test_kms_keyring.py::TestKmsKeyringOnDecrypt::test_on_decrypt_fails_with_mismatched_encryption_context`; Integration: `test/integration/test_i_s3_encryption.py::test_encryption_context_mismatch` | + +--- + +## Round-Trip Tests + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| RT1 | Default config, small body | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_simple_roundtrip_ascii_string[KC_GCM]` | +| RT2 | Default config + EncryptionContext | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_encryption_context_roundtrip[KC_GCM]` | +| RT3 | Large body (> 1 MB) | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_delayed_auth_large_object` (1 MB) and `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_large_v2_instruction_file_delayed_auth` (50 MB) | +| RT4 | Empty body (0 bytes) | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_empty_body_roundtrip` and `test/integration/test_i_s3_encryption.py::test_no_body_roundtrip` | +| RT5 | GCM_IV12_NO_KDF + FORBID | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_simple_roundtrip_ascii_string[AES_GCM]` | +| RT6 | Delayed authentication | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_delayed_auth_roundtrip` and `test/integration/test_i_s3_encryption.py::test_delayed_authentication_mode` | + +--- + +## Negative / Validation Cases — Encryption + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| EN1 | Reject legacy suite for encryption | ✅ Covered | Unit | `test/test_key_commitment_encrypt.py` — legacy CBC rejected at config time | +| EN2 | GCM_IV12 + REQUIRE_ENCRYPT_REQUIRE_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment_encrypt.py::TestRequireEncryptRejectsNonCommitting::test_require_encrypt_require_decrypt_rejects_non_committing_gcm`; Integration: `test/integration/test_i_key_commitment_policy.py::TestEncryptPolicyRejection::test_require_encrypt_require_decrypt_rejects_non_committing` | +| EN3 | GCM_IV12 + REQUIRE_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment_encrypt.py::TestRequireEncryptRejectsNonCommitting::test_require_encrypt_allow_decrypt_rejects_non_committing_gcm`; Integration: `test/integration/test_i_key_commitment_policy.py::TestEncryptPolicyRejection::test_require_encrypt_allow_decrypt_rejects_non_committing` | +| EN4 | GCM_HKDF_COMMIT + FORBID_ENCRYPT_ALLOW_DECRYPT | ✅ Covered | Both | Unit: `test/test_key_commitment_encrypt.py::TestForbidEncryptRejectsCommitting::test_forbid_encrypt_allow_decrypt_rejects_committing_gcm`; Integration: `test/integration/test_i_key_commitment_policy.py::TestEncryptPolicyRejection::test_forbid_encrypt_allow_decrypt_rejects_committing` | + +--- + +## Negative / Validation Cases — Decryption + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| DN1 | V1 CBC rejected when legacy disabled | ✅ Covered | Unit | `test/test_decryption.py::TestCBCDecryption::test_cbc_object_rejected_when_legacy_disabled` and `test/test_decryption.py::TestLegacyDecryption::test_legacy_cbc_rejected_by_default` | +| DN2 | V1 CBC, legacy enabled but legacy wrapping disabled | ✅ Covered | Unit | `test/test_kms_keyring.py::TestKmsKeyringOnDecrypt::test_on_decrypt_rejects_kms_v1_when_legacy_disabled` | +| DN3 | V2 non-committing + REQUIRE_REQUIRE | ✅ Covered | Both | Unit: `test/test_key_commitment.py::TestCommitmentPolicy::test_require_require_rejects_non_committing_decrypt` and `test/test_decryption.py::TestKeyCommitmentPolicy::test_require_decrypt_rejects_non_committing_suite`; Integration: `test/integration/test_i_key_commitment_policy.py::TestNonCommittingObjectDecryptPolicies::test_require_require_rejects_non_committing` | +| DN4 | Mismatched EncryptionContext | ✅ Covered | Both | Unit: `test/test_kms_keyring.py::TestKmsKeyringOnDecrypt::test_on_decrypt_fails_with_mismatched_encryption_context`; Integration: `test/integration/test_i_s3_encryption.py::test_encryption_context_mismatch` | +| DN5 | Reserved key in EncryptionContext | ✅ Covered | Unit | `test/test_kms_keyring.py::TestKmsKeyringOnDecrypt::test_on_decrypt_rejects_reserved_key_in_request_context` | + +--- + +## Negative / Validation Cases — Instruction File + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| IF1 | Instruction file missing from S3 | 🔄 In progress | Unit | `test/test_pipelines.py::TestGetEncryptedObjectPipelineInstructionFile::test_decrypt_instruction_file_s3_not_found_raises` | +| IF2 | Instruction file contains invalid JSON | ✅ Covered | Both | Unit: `test/test_s3_encryption_client_plugin.py::test_instruction_file_mode_invalid_json_raises_error`; Integration: `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_invalid_instruction_file` | +| IF3 | Instruction file suffix mismatch | 🔄 In progress | Integration | `test/integration/test_i_s3_encryption_instruction_file.py::test_decrypt_instruction_file_wrong_suffix_raises` | +| IF4 | Instruction file exists but has no body | 🔄 In progress | Unit | `test/test_pipelines.py::TestGetEncryptedObjectPipelineInstructionFile::test_decrypt_instruction_file_empty_metadata_raises` | + +--- + +## Negative / Validation Cases — General + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| G1 | Unsupported Body type | ✅ Covered | Integration | `test/integration/test_i_s3_encryption.py::test_invalid_body_types` | +| G2 | put_object in instruction-file mode | 🔄 In progress | Unit | `test/test_s3_encryption_client_plugin.py::TestS3EncryptionClientPlugin::test_put_object_rejects_instruction_file_mode` | +| G3 | Instruction file fetch with no s3_client | 🔄 In progress | Unit | `test/test_pipelines.py::TestGetEncryptedObjectPipelineInstructionFile::test_decrypt_instruction_file_no_s3_client_raises` | +| G4 | Instruction file fetch with missing Bucket/Key | 🔄 In progress | Unit | `test/test_pipelines.py::TestGetEncryptedObjectPipelineInstructionFile::test_decrypt_instruction_file_missing_bucket_key_raises` | + +--- + +## Streaming / Delayed Authentication + +| # | Description | Status | Type | Test Location | +|---|-------------|--------|------|---------------| +| S1 | Buffered withholds plaintext until tag verified | ✅ Covered | Unit | `test/test_stream.py::TestBufferedWithholdsUntilVerification::test_buffered_verifies_tag_before_releasing_any_plaintext` | +| S2 | Delayed auth releases plaintext before tag verification | ✅ Covered | Unit | `test/test_stream.py::TestDelayedAuthReleasesBeforeVerification::test_delayed_auth_releases_plaintext_before_tag_verification` | +| S3 | Both modes produce identical plaintext | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_buffered_and_delayed_produce_same_plaintext` | +| S4 | Chunked / partial reads | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_buffered_partial_reads` and `test_delayed_auth_chunked_reads` | +| S5 | Empty body round-trip both modes | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_empty_body_roundtrip` (parametrized buffered + delayed-auth) | +| S6 | Large object delayed-auth streaming | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_streaming.py::test_delayed_auth_large_object` (1 MB) | +| S7 | CBC always streams regardless of flag | ✅ Covered | Unit | `test/test_stream.py::TestDelayedAuthCBCDecryption` (full suite of CBC streaming tests) | +| S8 | Tampered ciphertext detected (buffered) | ✅ Covered | Unit | `test/test_stream.py::TestBufferedDecryptingStream::test_tampered_ciphertext_raises_error` | +| S9 | Tampered tag detected (delayed auth) | ✅ Covered | Unit | `test/test_stream.py::TestDelayedAuthGCMDecryption::test_tampered_tag_raises_error` | + +--- + +## Cross-Cutting Concerns + +| Concern | Status | Type | Test Location | +|---------|--------|------|---------------| +| Thread safety | ✅ Covered | Integration | `test/integration/test_i_s3_encryption_multithreaded.py` (3 tests: isolation, rapid switching, mixed) | +| Custom CMM | ❌ TODO | — | No end-to-end test with a user-provided CMM | +| Custom keyring | ❌ TODO | — | No end-to-end test with a user-implemented `AbstractKeyring` | +| Multi-region KMS keys | ❌ TODO | — | No test for cross-region encrypt/decrypt | +| Error propagation | ✅ Covered | Unit | `test/test_exceptions.py` (both error classes, inheritance from `BotoCoreError`) | +| Instruction file edge cases | 🔄 In progress | Both | Unit: invalid JSON, invalid keys, missing file, empty body; Integration: invalid instruction file; suffix mismatch is in progress| + +--- + +## Summary + +- Total test cases: 53 (E1–E8, D1–D13, RT1–RT6, EN1–EN4, DN1–DN5, IF1–IF4, G1–G4, S1–S9) +- Covered: 42 +- In progress (this PR): 9 (E5, D4, D5, IF1, IF3, IF4, G2, G3, G4) +- TODO: 2 (E7, E8) From 68aa598e0d4a52b543ce1574cc2dd863dbe7a58c Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 7 Apr 2026 13:25:45 -0700 Subject: [PATCH 05/11] proxy __getattr__ to wrapped client, add several more tests --- src/s3_encryption/__init__.py | 9 + test/integration/test_i_custom_keyring_cmm.py | 239 ++++++++++++++++++ test/integration/test_i_s3_encryption.py | 89 +++++++ test/test_pipelines.py | 2 +- 4 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 test/integration/test_i_custom_keyring_cmm.py diff --git a/src/s3_encryption/__init__.py b/src/s3_encryption/__init__.py index 7ece424c..0ebda35f 100644 --- a/src/s3_encryption/__init__.py +++ b/src/s3_encryption/__init__.py @@ -328,6 +328,15 @@ def __attrs_post_init__(self): event_system.register("before-call.s3.PutObject", self._plugin.on_put_object_before_call) event_system.register("after-call.s3.GetObject", self._plugin.on_get_object_after_call) + def __getattr__(self, name): + """Proxy unrecognized attributes to the wrapped S3 client. + + This allows the S3EncryptionClient to be used like a regular boto3 S3 + client for operations it doesn't intercept (e.g. copy_object, + delete_object, list_objects_v2, etc.). + """ + return getattr(self.wrapped_s3_client, name) + def put_object(self, **kwargs): """Encrypt and upload an object to S3. diff --git a/test/integration/test_i_custom_keyring_cmm.py b/test/integration/test_i_custom_keyring_cmm.py new file mode 100644 index 00000000..45cd3441 --- /dev/null +++ b/test/integration/test_i_custom_keyring_cmm.py @@ -0,0 +1,239 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Integration tests for custom keyring and custom CMM. + +These tests verify that user-implemented AbstractKeyring and +AbstractCryptoMaterialsManager subclasses work end-to-end through +S3EncryptionClient.put_object / get_object. + +WARNING: The custom classes below are test-only stubs that duplicate the +built-in KmsKeyring and DefaultCryptoMaterialsManager logic. They exist +solely to prove the extension points work. Do NOT use them in production. +""" + +import os +from datetime import datetime + +import boto3 + +from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig +from s3_encryption.exceptions import S3EncryptionClientError +from s3_encryption.materials.crypto_materials_manager import AbstractCryptoMaterialsManager +from s3_encryption.materials.encrypted_data_key import EncryptedDataKey +from s3_encryption.materials.keyring import S3Keyring +from s3_encryption.materials.materials import ( + AlgorithmSuite, + CommitmentPolicy, + DecryptionMaterials, + EncryptionMaterials, +) + +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" +) + +KMS_CONTEXT_DEFAULT_KEY = "aws:x-amz-cek-alg" + + +# --------------------------------------------------------------------------- +# Custom keyring — test-only, do NOT use in production code. +# Duplicates KmsKeyring logic to prove the AbstractKeyring extension point. +# --------------------------------------------------------------------------- + + +class CustomTestKmsKeyring(S3Keyring): + """Test-only KMS keyring. Do NOT use in production.""" + + def __init__(self, kms_client, kms_key_id): + self.kms_client = kms_client + self.kms_key_id = kms_key_id + + def on_encrypt(self, enc_materials): + enc_materials = super().on_encrypt(enc_materials) + encryption_context = enc_materials.encryption_context + + if ( + enc_materials.encryption_algorithm + == AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY + ): + encryption_context[KMS_CONTEXT_DEFAULT_KEY] = str( + enc_materials.encryption_algorithm.suite_id + ) + else: + encryption_context[KMS_CONTEXT_DEFAULT_KEY] = ( + enc_materials.encryption_algorithm.cipher_name + ) + + response = self.kms_client.generate_data_key( + KeyId=self.kms_key_id, KeySpec="AES_256", EncryptionContext=encryption_context + ) + enc_materials.encrypted_data_key = EncryptedDataKey( + key_provider_id=b"S3Keyring", + key_provider_info="kms+context", + encrypted_data_key=response["CiphertextBlob"], + ) + enc_materials.plaintext_data_key = response["Plaintext"] + return enc_materials + + def on_decrypt(self, dec_materials, encrypted_data_keys=None): + dec_materials = super().on_decrypt(dec_materials, encrypted_data_keys) + edks = ( + encrypted_data_keys + if encrypted_data_keys is not None + else dec_materials.encrypted_data_keys + ) + edk = edks[0] + + if edk.key_provider_info == "kms+context": + ec_from_request = dec_materials.encryption_context_from_request + ec_stored = dec_materials.encryption_context_stored + + if KMS_CONTEXT_DEFAULT_KEY in ec_from_request: + raise S3EncryptionClientError(f"{KMS_CONTEXT_DEFAULT_KEY} is a reserved key") + + ec_stored_copy = ec_stored.copy() + ec_stored_copy.pop("kms_cmk_id", None) + ec_stored_copy.pop(KMS_CONTEXT_DEFAULT_KEY, None) + + if ec_stored_copy != ec_from_request: + raise S3EncryptionClientError("Provided encryption context does not match") + elif edk.key_provider_info != "kms": + raise S3EncryptionClientError( + f"{edk.key_provider_info} is not a valid key wrapping algorithm!" + ) + + response = self.kms_client.decrypt( + KeyId=self.kms_key_id, + CiphertextBlob=edk.encrypted_data_key, + EncryptionContext=dec_materials.encryption_context_stored, + ) + dec_materials.plaintext_data_key = response["Plaintext"] + return dec_materials + + +# --------------------------------------------------------------------------- +# Custom CMM — test-only, do NOT use in production code. +# Duplicates DefaultCryptoMaterialsManager logic to prove the CMM extension point. +# --------------------------------------------------------------------------- + + +class CustomTestCMM(AbstractCryptoMaterialsManager): + """Test-only CMM. Do NOT use in production.""" + + def __init__(self, keyring): + self.keyring = keyring + + def get_encryption_materials(self, enc_mats_request): + if isinstance(enc_mats_request, dict): + materials = EncryptionMaterials( + encryption_context=enc_mats_request.get("encryption_context", {}) + ) + else: + materials = enc_mats_request + return self.keyring.on_encrypt(materials) + + def decrypt_materials(self, dec_mats_request): + if isinstance(dec_mats_request, dict): + materials = DecryptionMaterials.from_dict(dec_mats_request) + else: + materials = dec_mats_request + return self.keyring.on_decrypt(materials, materials.encrypted_data_keys) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _unique_key(prefix): + return prefix + datetime.now().strftime("%Y-%m-%d-%H:%M:%S-%f") + + +# --------------------------------------------------------------------------- +# Integration tests +# --------------------------------------------------------------------------- + + +class TestCustomKeyring: + """Verify a user-implemented AbstractKeyring subclass works end-to-end.""" + + def test_roundtrip_with_custom_keyring(self): + """Custom keyring MUST encrypt and decrypt successfully.""" + kms_client = boto3.client("kms", region_name=region) + keyring = CustomTestKmsKeyring(kms_client, kms_key_id) + wrapped_client = boto3.client("s3") + config = S3EncryptionClientConfig(keyring=keyring) + s3ec = S3EncryptionClient(wrapped_client, config) + + key = _unique_key("custom-keyring-rt-") + data = b"custom keyring round trip test" + + s3ec.put_object(Bucket=bucket, Key=key, Body=data) + response = s3ec.get_object(Bucket=bucket, Key=key) + assert response["Body"].read() == data + + def test_roundtrip_with_custom_keyring_aes_gcm(self): + """Custom keyring MUST work with non-committing AES-GCM suite.""" + kms_client = boto3.client("kms", region_name=region) + keyring = CustomTestKmsKeyring(kms_client, kms_key_id) + wrapped_client = boto3.client("s3") + config = S3EncryptionClientConfig( + keyring=keyring, + encryption_algorithm=AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + commitment_policy=CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + ) + s3ec = S3EncryptionClient(wrapped_client, config) + + key = _unique_key("custom-keyring-gcm-rt-") + data = b"custom keyring AES-GCM round trip" + + s3ec.put_object(Bucket=bucket, Key=key, Body=data) + response = s3ec.get_object(Bucket=bucket, Key=key) + assert response["Body"].read() == data + + +class TestCustomCMM: + """Verify a user-implemented AbstractCryptoMaterialsManager subclass works end-to-end.""" + + def test_roundtrip_with_custom_cmm(self): + """Custom CMM MUST encrypt and decrypt successfully.""" + from s3_encryption.materials.kms_keyring import KmsKeyring + + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, kms_key_id) + custom_cmm = CustomTestCMM(keyring) + wrapped_client = boto3.client("s3") + config = S3EncryptionClientConfig(keyring=keyring, cmm=custom_cmm) + s3ec = S3EncryptionClient(wrapped_client, config) + + key = _unique_key("custom-cmm-rt-") + data = b"custom CMM round trip test" + + s3ec.put_object(Bucket=bucket, Key=key, Body=data) + response = s3ec.get_object(Bucket=bucket, Key=key) + assert response["Body"].read() == data + + def test_roundtrip_with_custom_cmm_aes_gcm(self): + """Custom CMM MUST work with non-committing AES-GCM suite.""" + from s3_encryption.materials.kms_keyring import KmsKeyring + + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, kms_key_id) + custom_cmm = CustomTestCMM(keyring) + wrapped_client = boto3.client("s3") + config = S3EncryptionClientConfig( + keyring=keyring, + cmm=custom_cmm, + encryption_algorithm=AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + commitment_policy=CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, + ) + s3ec = S3EncryptionClient(wrapped_client, config) + + key = _unique_key("custom-cmm-gcm-rt-") + data = b"custom CMM AES-GCM round trip" + + s3ec.put_object(Bucket=bucket, Key=key, Body=data) + response = s3ec.get_object(Bucket=bucket, Key=key) + assert response["Body"].read() == data diff --git a/test/integration/test_i_s3_encryption.py b/test/integration/test_i_s3_encryption.py index 9aa0f6bb..790f89e5 100644 --- a/test/integration/test_i_s3_encryption.py +++ b/test/integration/test_i_s3_encryption.py @@ -290,3 +290,92 @@ def test_delayed_authentication_mode(enable_delayed_auth): s3ec.put_object(Bucket=bucket, Key=key, Body=data) response = s3ec.get_object(Bucket=bucket, Key=key) assert response["Body"].read() == data + + +def test_inaccessible_kms_key_raises_access_denied(): + """put_object with a KMS key we lack permission for MUST surface AccessDeniedException.""" + from botocore.exceptions import ClientError + + fake_key_arn = "arn:aws:kms:us-west-2:123456789012:key/00000000-0000-0000-0000-000000000000" + kms_client = boto3.client("kms", region_name=region) + keyring = KmsKeyring(kms_client, fake_key_arn) + wrapped_client = boto3.client("s3") + config = S3EncryptionClientConfig(keyring=keyring) + s3ec = S3EncryptionClient(wrapped_client, config) + + key = _unique_key("access-denied-") + + with pytest.raises(S3EncryptionClientError, match="Failed to encrypt object") as exc_info: + s3ec.put_object(Bucket=bucket, Key=key, Body=b"should fail") + + # Unwrap and verify the root cause is AccessDeniedException + cause = exc_info.value.__cause__ + assert isinstance(cause, ClientError) + assert cause.response["Error"]["Code"] == "AccessDeniedException" + + +def test_get_nonexistent_object_raises_no_such_key(): + """get_object for a key that doesn't exist MUST surface NoSuchKey.""" + from botocore.exceptions import ClientError + + s3ec = _make_client( + AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + ) + + with pytest.raises(S3EncryptionClientError, match="NoSuchKey") as exc_info: + s3ec.get_object(Bucket=bucket, Key="this-key-definitely-does-not-exist") + + cause = exc_info.value.__cause__ + assert isinstance(cause, ClientError) + assert cause.response["Error"]["Code"] == "NoSuchKey" + + +@pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS) +def test_s3_passthrough_options_preserved(algorithm_suite, commitment_policy): + """S3 options unrelated to encryption (e.g. StorageClass, ContentType) MUST be applied.""" + key = _unique_key("passthrough-opts-") + data = b'{"message": "hello"}' + + s3ec = _make_client(algorithm_suite, commitment_policy) + s3ec.put_object( + Bucket=bucket, + Key=key, + Body=data, + StorageClass="STANDARD_IA", + ContentType="application/json", + ContentDisposition="attachment; filename=test.json", + ) + + # Read back with head_object via the S3EC instance to verify the options were applied + head = s3ec.head_object(Bucket=bucket, Key=key) + assert head["StorageClass"] == "STANDARD_IA" + assert head["ContentType"] == "application/json" + assert head["ContentDisposition"] == "attachment; filename=test.json" + + # Also verify the data round-trips correctly + response = s3ec.get_object(Bucket=bucket, Key=key) + assert response["Body"].read() == data + + +@pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS) +def test_copy_object_then_decrypt(algorithm_suite, commitment_policy): + """An encrypted object copied via CopyObject MUST still decrypt correctly.""" + src_key = _unique_key("copy-src-") + dst_key = _unique_key("copy-dst-") + data = b"copy object round trip test" + + s3ec = _make_client(algorithm_suite, commitment_policy) + s3ec.put_object(Bucket=bucket, Key=src_key, Body=data) + + # Copy using the S3EC instance (copy_object proxies to the wrapped S3 client) + s3ec.copy_object( + Bucket=bucket, + Key=dst_key, + CopySource={"Bucket": bucket, "Key": src_key}, + MetadataDirective="COPY", + ) + + # Decrypt the copied object + response = s3ec.get_object(Bucket=bucket, Key=dst_key) + assert response["Body"].read() == data diff --git a/test/test_pipelines.py b/test/test_pipelines.py index 9f349140..a66880e4 100644 --- a/test/test_pipelines.py +++ b/test/test_pipelines.py @@ -392,7 +392,7 @@ def test_decrypt_instruction_file_s3_not_found_raises(self): "Metadata": object_metadata, } - with pytest.raises(ClientError): + with pytest.raises(S3EncryptionClientError, match="Instruction File"): pipeline.decrypt( mock_response, instruction_suffix=".instruction", From f4d545e474bbafe2f4e837f7603958edef134462 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 7 Apr 2026 13:37:27 -0700 Subject: [PATCH 06/11] fix inst file test --- test/integration/test_i_s3_encryption_instruction_file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/test_i_s3_encryption_instruction_file.py b/test/integration/test_i_s3_encryption_instruction_file.py index ba96f74c..d93a2922 100644 --- a/test/integration/test_i_s3_encryption_instruction_file.py +++ b/test/integration/test_i_s3_encryption_instruction_file.py @@ -145,7 +145,7 @@ def test_decrypt_instruction_file_wrong_suffix_raises(): ) s3ec = S3EncryptionClient(wrapped_client, config) - with pytest.raises(S3EncryptionClientError, match="Failed to decrypt object"): + with pytest.raises(S3EncryptionClientError, match="Instruction file body is empty"): s3ec.get_object(Bucket=bucket, Key=key) From df0c0f1597978073e7888e809c0fe485b2a0eaf0 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 7 Apr 2026 14:50:16 -0700 Subject: [PATCH 07/11] split coverage between unit and integ tests --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 256f50b7..4fe8ee19 100644 --- a/Makefile +++ b/Makefile @@ -23,13 +23,13 @@ format: # Run all tests with combined coverage test: test-unit test-integration -# Run unit tests (creates .coverage report) +# Run unit tests with coverage test-unit: - uv run pytest test/ --ignore=test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing + uv run pytest test/ --ignore=test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=89 -# Run integration tests (appends to .coverage report from test-unit) +# Run integration tests with separate coverage test-integration: - uv run pytest test/integration/ --verbose --cov=src/s3_encryption --cov-append --cov-report=term-missing + uv run pytest test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=83 # Clean up cache files clean: From 83283e5f2181fdc0f9a630ceebe2261a8b700b12 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 7 Apr 2026 15:32:21 -0700 Subject: [PATCH 08/11] add MRK keys, MRK test --- cdk/lib/cdk-stack.ts | 31 +++++ test/integration/test_i_mrk_cross_region.py | 122 ++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 test/integration/test_i_mrk_cross_region.py diff --git a/cdk/lib/cdk-stack.ts b/cdk/lib/cdk-stack.ts index b5a28084..329e16ca 100644 --- a/cdk/lib/cdk-stack.ts +++ b/cdk/lib/cdk-stack.ts @@ -65,6 +65,33 @@ export class S3ECPythonGithub extends cdk.Stack { } ) + // Multi-Region Key (MRK) for cross-region testing. + // The primary key is created here in the stack's region (us-west-2). + // A replica MUST be created manually in us-east-1 via the AWS Console + // or a separate CDK stack, since CDK cannot create cross-region replicas + // within a single stack. + const S3ECMRKPrimaryKey = new Key( + this, + "S3ECMRKPrimaryKey", + { + enableKeyRotation: true, + description: "Multi-Region primary key for S3EC cross-region testing", + // multiRegion is not a direct CDK L2 prop; use cfnOptions override + } + ); + // Override to enable multi-region on the underlying CloudFormation resource + const cfnMrkKey = S3ECMRKPrimaryKey.node.defaultChild as cdk.aws_kms.CfnKey; + cfnMrkKey.addPropertyOverride("MultiRegion", true); + + const S3ECMRKPrimaryKeyAlias = new Alias( + this, + "S3ECMRKPrimaryKeyAlias", + { + aliasName: "alias/S3EC-Python-MRK-Primary", + targetKey: S3ECMRKPrimaryKey, + } + ); + // S3 buckets const AccessConfiguration: BlockPublicAccessOptions = { blockPublicAcls: false, @@ -162,6 +189,10 @@ export class S3ECPythonGithub extends cdk.Stack { resources: [ S3ECGithubKMSKey.keyArn, S3ECTestServerKMSKey.keyArn, // Add access to the test-server KMS key + S3ECMRKPrimaryKey.keyArn, // MRK primary key + // MRK replica in us-east-1 — ARN must use wildcard account + // since the replica shares the same key ID but different region + `arn:aws:kms:us-east-1:${this.account}:key/${S3ECMRKPrimaryKey.keyId}`, ] }) ] diff --git a/test/integration/test_i_mrk_cross_region.py b/test/integration/test_i_mrk_cross_region.py new file mode 100644 index 00000000..f54a7af2 --- /dev/null +++ b/test/integration/test_i_mrk_cross_region.py @@ -0,0 +1,122 @@ +# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Integration tests for Multi-Region Key (MRK) cross-region encrypt/decrypt. + +These tests verify that data encrypted with a KMS MRK primary key in one region +can be decrypted using the MRK replica in another region, and vice versa. + +Prerequisites: + - A KMS MRK primary key in us-west-2 (created by CDK stack) + - A KMS MRK replica of the same key in us-east-1 (created manually after CDK deploy) + - Both keys share the same key ID (mrk-...) but have different region ARNs + +Environment variables: + CI_MRK_KEY_ID_PRIMARY: ARN or alias of the MRK primary in us-west-2 + CI_MRK_KEY_ID_REPLICA: ARN of the MRK replica in us-east-1 + CI_S3_BUCKET: S3 bucket for test objects (us-west-2) +""" + +import os +from datetime import datetime + +import boto3 +import pytest + +from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig +from s3_encryption.exceptions import S3EncryptionClientError +from s3_encryption.materials.kms_keyring import KmsKeyring + +bucket = os.environ.get("CI_S3_BUCKET", "s3ec-python-github-test-bucket") +primary_region = os.environ.get("CI_AWS_REGION", "us-west-2") +replica_region = "us-east-1" + +mrk_primary = os.environ.get( + "CI_MRK_KEY_ID_PRIMARY", + "arn:aws:kms:us-west-2:370957321024:key/mrk-cea4cf67c6a046ba829f61f69db5c191", +) +mrk_replica = os.environ.get( + "CI_MRK_KEY_ID_REPLICA", + "arn:aws:kms:us-east-1:370957321024:key/mrk-cea4cf67c6a046ba829f61f69db5c191", +) + + +def _make_client(kms_region, kms_key_id): + """Create an S3EncryptionClient using a KMS client in the given region.""" + kms_client = boto3.client("kms", region_name=kms_region) + keyring = KmsKeyring(kms_client, kms_key_id) + wrapped_client = boto3.client("s3", region_name=primary_region) + config = S3EncryptionClientConfig(keyring=keyring) + return S3EncryptionClient(wrapped_client, config) + + +def _unique_key(prefix): + return prefix + datetime.now().strftime("%Y-%m-%d-%H:%M:%S-%f") + + +class TestMRKCrossRegion: + """Verify MRK encrypt/decrypt works across regions.""" + + def test_encrypt_primary_decrypt_replica(self): + """Data encrypted with MRK primary MUST decrypt with MRK replica.""" + key = _unique_key("mrk-primary-to-replica-") + data = b"MRK cross-region: primary -> replica" + + writer = _make_client(primary_region, mrk_primary) + writer.put_object(Bucket=bucket, Key=key, Body=data) + + reader = _make_client(replica_region, mrk_replica) + response = reader.get_object(Bucket=bucket, Key=key) + assert response["Body"].read() == data + + def test_encrypt_replica_decrypt_primary(self): + """Data encrypted with MRK replica MUST decrypt with MRK primary.""" + key = _unique_key("mrk-replica-to-primary-") + data = b"MRK cross-region: replica -> primary" + + writer = _make_client(replica_region, mrk_replica) + writer.put_object(Bucket=bucket, Key=key, Body=data) + + reader = _make_client(primary_region, mrk_primary) + response = reader.get_object(Bucket=bucket, Key=key) + assert response["Body"].read() == data + + def test_encrypt_and_decrypt_same_region_primary(self): + """MRK primary round-trip in the same region MUST work.""" + key = _unique_key("mrk-same-region-primary-") + data = b"MRK same-region primary round trip" + + s3ec = _make_client(primary_region, mrk_primary) + s3ec.put_object(Bucket=bucket, Key=key, Body=data) + response = s3ec.get_object(Bucket=bucket, Key=key) + assert response["Body"].read() == data + + def test_encrypt_and_decrypt_same_region_replica(self): + """MRK replica round-trip in the same region MUST work.""" + key = _unique_key("mrk-same-region-replica-") + data = b"MRK same-region replica round trip" + + s3ec = _make_client(replica_region, mrk_replica) + s3ec.put_object(Bucket=bucket, Key=key, Body=data) + response = s3ec.get_object(Bucket=bucket, Key=key) + assert response["Body"].read() == data + + +class TestMRKNonReplicatedRegionFails: + """Verify that using an MRK in a region where it hasn't been replicated fails.""" + + def test_decrypt_with_wrong_region_kms_client_fails(self): + """Decrypting with a KMS client pointed at a non-replicated region MUST fail.""" + key = _unique_key("mrk-wrong-region-") + data = b"MRK wrong region test" + + # Encrypt with primary + writer = _make_client(primary_region, mrk_primary) + writer.put_object(Bucket=bucket, Key=key, Body=data) + + # Try to decrypt using a KMS client in a region where the MRK doesn't exist. + # Use eu-west-1 as a region that almost certainly has no replica. + non_replicated_region = "eu-west-1" + reader = _make_client(non_replicated_region, mrk_primary) + + with pytest.raises(S3EncryptionClientError): + reader.get_object(Bucket=bucket, Key=key) From 00e5bfcb76fc6d9945e3cc0beb2ce08dec49911b Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 7 Apr 2026 15:41:01 -0700 Subject: [PATCH 09/11] fix coverage report --- .github/workflows/python-integ.yml | 35 +++++++++++++++--------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/workflows/python-integ.yml b/.github/workflows/python-integ.yml index b845e725..ab377c61 100644 --- a/.github/workflows/python-integ.yml +++ b/.github/workflows/python-integ.yml @@ -48,31 +48,32 @@ jobs: aws-region: us-west-2 - name: Run unit tests - run: make test-unit + run: | + uv run pytest test/ --ignore=test/integration/ --verbose \ + --cov=src/s3_encryption --cov-report=term-missing --cov-report=html:coverage-unit \ + --cov-fail-under=89 - name: Run integration tests - run: make test-integration + run: | + uv run pytest test/integration/ --verbose \ + --cov=src/s3_encryption --cov-report=term-missing --cov-report=html:coverage-integ \ + --cov-fail-under=83 env: CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }} CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }} + CI_MRK_KEY_ID_PRIMARY: ${{ vars.CI_MRK_KEY_ID_PRIMARY }} + CI_MRK_KEY_ID_REPLICA: ${{ vars.CI_MRK_KEY_ID_REPLICA }} - - name: Generate coverage HTML report + - name: Upload unit test coverage report if: always() - run: uv run coverage html -d coverage-report + uses: actions/upload-artifact@v7 + with: + name: coverage-unit + path: coverage-unit/ - - name: Upload coverage report + - name: Upload integration test coverage report if: always() uses: actions/upload-artifact@v7 with: - name: coverage-report - path: coverage-report/ - - - name: Check coverage threshold - run: | - THRESHOLD=93 - ACTUAL=$(uv run coverage report --format=total) - echo "Coverage: ${ACTUAL}% (threshold: ${THRESHOLD}%)" - if [ "$ACTUAL" -gt "$THRESHOLD" ]; then - echo "::warning::Coverage is ${ACTUAL}%, consider updating --fail-under to ${ACTUAL} in python-integ.yml" - fi - uv run coverage report --fail-under=$THRESHOLD + name: coverage-integ + path: coverage-integ/ From 0f5d32af983b89922161229dae16ad54817f8d6a Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 7 Apr 2026 16:24:05 -0700 Subject: [PATCH 10/11] validate against non-ASCII chars --- src/s3_encryption/__init__.py | 30 ++++++++++++++++++++++++ test/integration/test_i_s3_encryption.py | 22 +++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/s3_encryption/__init__.py b/src/s3_encryption/__init__.py index 0ebda35f..b8221051 100644 --- a/src/s3_encryption/__init__.py +++ b/src/s3_encryption/__init__.py @@ -300,6 +300,34 @@ def process_instruction_file(self, parsed): parsed["Body"] = streaming_body +def _validate_encryption_context(encryption_context): + """Validate that all encryption context keys and values are US-ASCII. + + S3 applies double-encoding to non-ASCII metadata values that SDKs do not + automatically decode, which causes decryption to fail because the stored + encryption context won't match the original. + + Raises: + S3EncryptionClientError: If any key or value contains non-ASCII characters. + """ + if encryption_context is None: + return + if not isinstance(encryption_context, dict): + raise S3EncryptionClientError("EncryptionContext must be a dictionary") + for k, v in encryption_context.items(): + if not isinstance(k, str) or not isinstance(v, str): + raise S3EncryptionClientError( + "EncryptionContext keys and values must be strings" + ) + if not k.isascii() or not v.isascii(): + raise S3EncryptionClientError( + f"EncryptionContext keys and values must contain only US-ASCII characters. " + f"Non-ASCII characters in S3 metadata are encoded by the server " + f"and will cause decryption to fail. " + f"First offending entry: {repr(k)}: {repr(v)}" + ) + + @define class S3EncryptionClient: """Client for encrypting and decrypting S3 objects. @@ -358,6 +386,7 @@ def put_object(self, **kwargs): """ # Extract EncryptionContext if provided (not a standard S3 parameter) encryption_context = kwargs.pop("EncryptionContext", None) + _validate_encryption_context(encryption_context) # Store encryption context in thread-local storage for the event handler self._plugin._context.encryption_context = encryption_context @@ -393,6 +422,7 @@ def get_object(self, **kwargs): """ # Extract EncryptionContext if provided (not a standard S3 parameter) encryption_context = kwargs.pop("EncryptionContext", None) + _validate_encryption_context(encryption_context) # Store encryption context in thread-local storage for the event handler setattr(self._plugin._context, _CTX_ENCRYPTION_CONTEXT, encryption_context) diff --git a/test/integration/test_i_s3_encryption.py b/test/integration/test_i_s3_encryption.py index 790f89e5..7a016156 100644 --- a/test/integration/test_i_s3_encryption.py +++ b/test/integration/test_i_s3_encryption.py @@ -379,3 +379,25 @@ def test_copy_object_then_decrypt(algorithm_suite, commitment_policy): # Decrypt the copied object response = s3ec.get_object(Bucket=bucket, Key=dst_key) assert response["Body"].read() == data +@pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS) +def test_non_ascii_encryption_context_rejected(algorithm_suite, commitment_policy): + """Non-US-ASCII characters in EncryptionContext MUST be rejected. + + S3 applies an esoteric double-encoding to non-ASCII metadata values that + most SDKs do not automatically decode. This causes decryption to fail + because the stored encryption context won't match the original. Currently + boto3 rejects non-ASCII header values before the request is sent. + """ + key = _unique_key("non-ascii-ec-") + non_ascii_contexts = [ + {"department": "ingeniería"}, # Latin accented + {"部門": "engineering"}, # CJK key + {"project": "проект"}, # Cyrillic value + {"emoji": "test 🔑"}, # Emoji + ] + + s3ec = _make_client(algorithm_suite, commitment_policy) + + for ec in non_ascii_contexts: + with pytest.raises(S3EncryptionClientError, match="US-ASCII"): + s3ec.put_object(Bucket=bucket, Key=key, Body=b"test", EncryptionContext=ec) From 6fb7cb2c06fafd8c5eee094c1042230dab2626a3 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 7 Apr 2026 16:26:02 -0700 Subject: [PATCH 11/11] format --- src/s3_encryption/__init__.py | 4 +--- test/integration/test_i_s3_encryption.py | 10 ++++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/s3_encryption/__init__.py b/src/s3_encryption/__init__.py index b8221051..6de14e96 100644 --- a/src/s3_encryption/__init__.py +++ b/src/s3_encryption/__init__.py @@ -316,9 +316,7 @@ def _validate_encryption_context(encryption_context): raise S3EncryptionClientError("EncryptionContext must be a dictionary") for k, v in encryption_context.items(): if not isinstance(k, str) or not isinstance(v, str): - raise S3EncryptionClientError( - "EncryptionContext keys and values must be strings" - ) + raise S3EncryptionClientError("EncryptionContext keys and values must be strings") if not k.isascii() or not v.isascii(): raise S3EncryptionClientError( f"EncryptionContext keys and values must contain only US-ASCII characters. " diff --git a/test/integration/test_i_s3_encryption.py b/test/integration/test_i_s3_encryption.py index 7a016156..91c4ce10 100644 --- a/test/integration/test_i_s3_encryption.py +++ b/test/integration/test_i_s3_encryption.py @@ -379,6 +379,8 @@ def test_copy_object_then_decrypt(algorithm_suite, commitment_policy): # Decrypt the copied object response = s3ec.get_object(Bucket=bucket, Key=dst_key) assert response["Body"].read() == data + + @pytest.mark.parametrize("algorithm_suite,commitment_policy", ALGORITHM_CONFIGS) def test_non_ascii_encryption_context_rejected(algorithm_suite, commitment_policy): """Non-US-ASCII characters in EncryptionContext MUST be rejected. @@ -390,10 +392,10 @@ def test_non_ascii_encryption_context_rejected(algorithm_suite, commitment_polic """ key = _unique_key("non-ascii-ec-") non_ascii_contexts = [ - {"department": "ingeniería"}, # Latin accented - {"部門": "engineering"}, # CJK key - {"project": "проект"}, # Cyrillic value - {"emoji": "test 🔑"}, # Emoji + {"department": "ingeniería"}, # Latin accented + {"部門": "engineering"}, # CJK key + {"project": "проект"}, # Cyrillic value + {"emoji": "test 🔑"}, # Emoji ] s3ec = _make_client(algorithm_suite, commitment_policy)