From 7808e6410b2be77d9f6b3a4b9a781687f390d75e Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 12 May 2026 14:14:29 -0700 Subject: [PATCH 1/7] chore: apply user agent string with version --- src/s3_encryption/__init__.py | 4 +++- src/s3_encryption/_utils.py | 12 ++++++++++++ src/s3_encryption/materials/kms_keyring.py | 5 +++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/s3_encryption/__init__.py b/src/s3_encryption/__init__.py index ad07fe43..3f188510 100644 --- a/src/s3_encryption/__init__.py +++ b/src/s3_encryption/__init__.py @@ -9,7 +9,7 @@ from botocore.exceptions import ClientError from botocore.response import StreamingBody -from ._utils import safe_get_dict +from ._utils import _USER_AGENT_SUFFIX, append_user_agent, safe_get_dict from .exceptions import S3EncryptionClientError from .instruction_file import parse_instruction_file from .instruction_file_config import InstructionFileConfig @@ -350,6 +350,8 @@ def __attrs_post_init__(self): # Expose plugin context on wrapped client for instruction file fetching self.wrapped_s3_client._s3ec_plugin_context = self._plugin._context + append_user_agent(self.wrapped_s3_client, _USER_AGENT_SUFFIX) + # Register event handlers using boto3's event system event_system = self.wrapped_s3_client.meta.events event_system.register("before-call.s3.PutObject", self._plugin.on_put_object_before_call) diff --git a/src/s3_encryption/_utils.py b/src/s3_encryption/_utils.py index 4997b973..7cab9e27 100644 --- a/src/s3_encryption/_utils.py +++ b/src/s3_encryption/_utils.py @@ -2,6 +2,11 @@ # SPDX-License-Identifier: Apache-2.0 """Internal utility helpers for the S3 Encryption Client.""" +import importlib.metadata + +_PACKAGE_VERSION = importlib.metadata.version("amazon-s3-encryption-client-python") +_USER_AGENT_SUFFIX = f"S3ECPy/{_PACKAGE_VERSION}" + def safe_get_dict(source: dict, key: str) -> dict: """Get a dict value from *source*, defaulting to {} if missing or None. @@ -10,3 +15,10 @@ def safe_get_dict(source: dict, key: str) -> dict: when the key exists but its value is explicitly None. """ return source.get(key, {}) or {} + + +def append_user_agent(client, suffix: str): + """Append a suffix to the User-Agent header of a boto3 client.""" + existing = client.meta.config.user_agent_extra or "" + sep = " " if existing else "" + client.meta.config.user_agent_extra = f"{existing}{sep}{suffix}" diff --git a/src/s3_encryption/materials/kms_keyring.py b/src/s3_encryption/materials/kms_keyring.py index abd6fad4..6550a38b 100644 --- a/src/s3_encryption/materials/kms_keyring.py +++ b/src/s3_encryption/materials/kms_keyring.py @@ -53,6 +53,11 @@ class KmsKeyring(S3Keyring): ##% The KmsV1 mode MUST be only enabled when legacy wrapping algorithms are enabled. enable_legacy_wrapping_algorithms: bool = field(default=False) + def __attrs_post_init__(self): # noqa: D105 + from .._utils import _USER_AGENT_SUFFIX, append_user_agent + + append_user_agent(self.kms_client, _USER_AGENT_SUFFIX) + def on_encrypt(self, enc_materials): """Process encryption materials using KMS. From 2e89727e6a7f562258ae40bd084c1ef52fd22297 Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 12 May 2026 14:25:13 -0700 Subject: [PATCH 2/7] chore(test): fix flaky tests, add more platforms --- .github/workflows/python-integ.yml | 35 +++++++++------------ test/integration/test_i_security.py | 47 ++++++++++++++++++----------- 2 files changed, 43 insertions(+), 39 deletions(-) diff --git a/.github/workflows/python-integ.yml b/.github/workflows/python-integ.yml index e2d710cc..3085f9e2 100644 --- a/.github/workflows/python-integ.yml +++ b/.github/workflows/python-integ.yml @@ -4,14 +4,19 @@ on: workflow_call: inputs: python-version: - description: "Python version to use" + description: "Python version to use (ignored when matrix is used)" default: "3.11" required: false type: string jobs: python-integ: - runs-on: macos-14-large + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} permissions: id-token: write contents: read @@ -25,14 +30,16 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: ${{ inputs.python-version || '3.11' }} - + python-version: ${{ matrix.python-version }} + allow-prereleases: true + - name: Cache uv dependencies uses: actions/cache@v5 with: path: ~/.cache/uv - key: ${{ runner.os }}-uv-${{ hashFiles('./test-server/python-v4-server/**/pyproject.toml') }} + key: ${{ runner.os }}-uv-py${{ matrix.python-version }}-${{ hashFiles('**/pyproject.toml') }} restore-keys: | + ${{ runner.os }}-uv-py${{ matrix.python-version }}- ${{ runner.os }}-uv- - name: Install Uv @@ -50,13 +57,13 @@ jobs: - name: Run unit tests run: | uv run pytest test/ --ignore=test/integration/ --ignore=test/performance/ --verbose \ - --cov=src/s3_encryption --cov-report=term-missing --cov-report=html:coverage-unit \ + --cov=src/s3_encryption --cov-report=term-missing \ --cov-fail-under=89 - name: Run integration tests run: | uv run pytest test/integration/ --verbose \ - --cov=src/s3_encryption --cov-report=term-missing --cov-report=html:coverage-integ \ + --cov=src/s3_encryption --cov-report=term-missing \ --cov-fail-under=83 env: CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }} @@ -66,17 +73,3 @@ jobs: - name: Run examples run: make test-examples - - - name: Generate coverage HTML report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-unit - path: coverage-unit/ - - - name: Upload integration test coverage report - if: always() - uses: actions/upload-artifact@v7 - with: - name: coverage-integ - path: coverage-integ/ diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index 67782c67..3864d153 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -462,11 +462,18 @@ def test_wrong_key_and_tampered_ciphertext_produce_same_error(self): iv = os.urandom(16) ciphertext = self._encrypt_cbc(key, iv, b"test data for padding oracle check") - # Wrong key: decryption produces garbage, unpadding fails - wrong_key = os.urandom(32) - decryptor1 = self._make_cbc_decryptor(wrong_key, iv, len(ciphertext)) - with pytest.raises(S3EncryptionClientSecurityError) as exc1: - decryptor1.finalize(ciphertext) + # Wrong key: decryption produces garbage, unpadding fails. + # ~1/256 chance random garbage has valid PKCS7 padding, so retry. + exc1 = None + for _ in range(10): + wrong_key = os.urandom(32) + decryptor1 = self._make_cbc_decryptor(wrong_key, iv, len(ciphertext)) + try: + decryptor1.finalize(ciphertext) + except S3EncryptionClientSecurityError as e: + exc1 = e + break + assert exc1 is not None, "Wrong key did not produce padding error after 10 attempts" # Tampered ciphertext: last byte flipped, unpadding fails tampered = ciphertext[:-1] + bytes([ciphertext[-1] ^ 0x01]) @@ -475,15 +482,14 @@ def test_wrong_key_and_tampered_ciphertext_produce_same_error(self): decryptor2.finalize(tampered) # Both MUST produce the same error message - assert str(exc1.value) == str(exc2.value), ( - f"Error messages differ: wrong_key={str(exc1.value)!r}, " - f"tampered={str(exc2.value)!r}" + assert str(exc1) == str(exc2.value), ( + f"Error messages differ: wrong_key={str(exc1)!r}, " f"tampered={str(exc2.value)!r}" ) # Neither message should contain details about the underlying failure assert ( - "padding" not in str(exc1.value).lower() - ), f"Error message leaks padding information: {str(exc1.value)!r}" + "padding" not in str(exc1).lower() + ), f"Error message leaks padding information: {str(exc1)!r}" def test_truncated_ciphertext_produces_same_error(self): """Truncated ciphertext MUST produce the same error as padding failure. @@ -496,11 +502,17 @@ def test_truncated_ciphertext_produces_same_error(self): iv = os.urandom(16) ciphertext = self._encrypt_cbc(key, iv, b"test data for truncation check") - # Padding failure (wrong key) - wrong_key = os.urandom(32) - decryptor1 = self._make_cbc_decryptor(wrong_key, iv, len(ciphertext)) - with pytest.raises(S3EncryptionClientSecurityError) as exc1: - decryptor1.finalize(ciphertext) + # Padding failure (wrong key) — retry for same reason as above + exc1 = None + for _ in range(10): + wrong_key = os.urandom(32) + decryptor1 = self._make_cbc_decryptor(wrong_key, iv, len(ciphertext)) + try: + decryptor1.finalize(ciphertext) + except S3EncryptionClientSecurityError as e: + exc1 = e + break + assert exc1 is not None, "Wrong key did not produce padding error after 10 attempts" # Truncated ciphertext (not block-aligned) truncated = ciphertext[:-3] @@ -509,9 +521,8 @@ def test_truncated_ciphertext_produces_same_error(self): decryptor2.finalize(truncated) # Both MUST produce the same error message - assert str(exc1.value) == str(exc2.value), ( - f"Error messages differ: padding_fail={str(exc1.value)!r}, " - f"truncated={str(exc2.value)!r}" + assert str(exc1) == str(exc2.value), ( + f"Error messages differ: padding_fail={str(exc1)!r}, " f"truncated={str(exc2.value)!r}" ) From 2262d15d449719f0106d07c811bfc210c152a58d Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 12 May 2026 14:36:20 -0700 Subject: [PATCH 3/7] make windows happy --- .github/workflows/python-integ.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/python-integ.yml b/.github/workflows/python-integ.yml index 3085f9e2..190d7463 100644 --- a/.github/workflows/python-integ.yml +++ b/.github/workflows/python-integ.yml @@ -55,16 +55,10 @@ jobs: aws-region: us-west-2 - name: Run unit tests - run: | - uv run pytest test/ --ignore=test/integration/ --ignore=test/performance/ --verbose \ - --cov=src/s3_encryption --cov-report=term-missing \ - --cov-fail-under=89 + run: uv run pytest test/ --ignore=test/integration/ --ignore=test/performance/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=89 - name: Run integration tests - run: | - uv run pytest test/integration/ --verbose \ - --cov=src/s3_encryption --cov-report=term-missing \ - --cov-fail-under=83 + run: uv run pytest test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=83 env: CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }} CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }} From 78a69131a452dc7db9df15f7d442e658311109ba Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 12 May 2026 15:21:51 -0700 Subject: [PATCH 4/7] add uuid to examples, use 3.10 --- .github/workflows/python-integ.yml | 2 +- examples/test/test_i_delayed_auth_streaming_example.py | 10 ++++++---- examples/test/test_i_kms_keyring_put_get_example.py | 10 ++++++---- examples/test/test_i_legacy_decrypt_example.py | 1 + pyproject.toml | 6 +++--- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/.github/workflows/python-integ.yml b/.github/workflows/python-integ.yml index 190d7463..3c5743ed 100644 --- a/.github/workflows/python-integ.yml +++ b/.github/workflows/python-integ.yml @@ -14,7 +14,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} permissions: diff --git a/examples/test/test_i_delayed_auth_streaming_example.py b/examples/test/test_i_delayed_auth_streaming_example.py index 501c7be0..d087789c 100644 --- a/examples/test/test_i_delayed_auth_streaming_example.py +++ b/examples/test/test_i_delayed_auth_streaming_example.py @@ -1,6 +1,9 @@ # Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 """Test suite for the delayed auth streaming decrypt example.""" + +import uuid + import boto3 import pytest @@ -9,11 +12,11 @@ pytestmark = [pytest.mark.examples] BUCKET = "s3ec-python-github-test-bucket" -KEY = "examples/delayed-auth-streaming" KMS_KEY_ID = "arn:aws:kms:us-west-2:370957321024:alias/S3EC-Python-Github-KMS-Key" def test_delayed_auth_streaming_decrypt(): + key = f"examples/delayed-auth-streaming-{uuid.uuid4()}" s3_client = boto3.client("s3", region_name="us-west-2") kms_client = boto3.client("kms", region_name="us-west-2") delayed_auth_streaming_decrypt( @@ -21,7 +24,6 @@ def test_delayed_auth_streaming_decrypt(): kms_client=kms_client, kms_key_id=KMS_KEY_ID, bucket=BUCKET, - key=KEY, + key=key, ) - # Clean up - s3_client.delete_object(Bucket=BUCKET, Key=KEY) + s3_client.delete_object(Bucket=BUCKET, Key=key) diff --git a/examples/test/test_i_kms_keyring_put_get_example.py b/examples/test/test_i_kms_keyring_put_get_example.py index 08759041..bff0e76f 100644 --- a/examples/test/test_i_kms_keyring_put_get_example.py +++ b/examples/test/test_i_kms_keyring_put_get_example.py @@ -1,6 +1,9 @@ # Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 """Test suite for the KMS Keyring put/get example.""" + +import uuid + import boto3 import pytest @@ -9,11 +12,11 @@ pytestmark = [pytest.mark.examples] BUCKET = "s3ec-python-github-test-bucket" -KEY = "examples/kms-keyring-put-get" KMS_KEY_ID = "arn:aws:kms:us-west-2:370957321024:alias/S3EC-Python-Github-KMS-Key" def test_kms_keyring_put_get(): + key = f"examples/kms-keyring-put-get-{uuid.uuid4()}" s3_client = boto3.client("s3", region_name="us-west-2") kms_client = boto3.client("kms", region_name="us-west-2") kms_keyring_put_get( @@ -21,7 +24,6 @@ def test_kms_keyring_put_get(): kms_client=kms_client, kms_key_id=KMS_KEY_ID, bucket=BUCKET, - key=KEY, + key=key, ) - # Clean up - s3_client.delete_object(Bucket=BUCKET, Key=KEY) + s3_client.delete_object(Bucket=BUCKET, Key=key) diff --git a/examples/test/test_i_legacy_decrypt_example.py b/examples/test/test_i_legacy_decrypt_example.py index 93f67d0c..b072d561 100644 --- a/examples/test/test_i_legacy_decrypt_example.py +++ b/examples/test/test_i_legacy_decrypt_example.py @@ -1,6 +1,7 @@ # Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 """Test suite for the legacy decrypt example.""" + import boto3 import pytest diff --git a/pyproject.toml b/pyproject.toml index 5e94ee4d..1279d051 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ authors = [ ] license = {text = "Apache-2.0"} readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.10" dependencies = [ "boto3>=1.43.6,<2", "cryptography>=48.0.0,<49", @@ -34,12 +34,12 @@ packages = ["src/s3_encryption"] [tool.black] line-length = 100 -target-version = ["py311"] +target-version = ["py310"] include = '\.pyi?$' [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py310" exclude = [".git", "__pycache__", "build", "dist"] [tool.ruff.lint] From be9ef944b017a6222d472d57fd7236140a2bb16e Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Tue, 12 May 2026 15:59:37 -0700 Subject: [PATCH 5/7] restore report --- .github/workflows/python-integ.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-integ.yml b/.github/workflows/python-integ.yml index 3c5743ed..6a23a7b3 100644 --- a/.github/workflows/python-integ.yml +++ b/.github/workflows/python-integ.yml @@ -55,10 +55,10 @@ jobs: aws-region: us-west-2 - name: Run unit tests - run: uv run pytest test/ --ignore=test/integration/ --ignore=test/performance/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=89 + run: uv run pytest test/ --ignore=test/integration/ --ignore=test/performance/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-report=html:coverage-unit --cov-fail-under=89 - name: Run integration tests - run: uv run pytest test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=83 + 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 }} @@ -67,3 +67,17 @@ jobs: - name: Run examples run: make test-examples + + - name: Upload unit test coverage report + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-unit-py${{ matrix.python-version }}-${{ matrix.os }} + path: coverage-unit/ + + - name: Upload integration test coverage report + if: always() + uses: actions/upload-artifact@v7 + with: + name: coverage-integ-py${{ matrix.python-version }}-${{ matrix.os }} + path: coverage-integ/ From da70abb27e010b44b542a30baad79d6a8d372dac Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Wed, 13 May 2026 14:35:25 -0700 Subject: [PATCH 6/7] lint --- test/integration/test_i_security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index 2684650f..9a7f22d2 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -522,7 +522,7 @@ def test_truncated_ciphertext_produces_same_error(self): # Both MUST produce the same error message assert str(exc1) == str(exc2.value), ( - f"Error messages differ: padding_fail={str(exc1)!r}, " f"truncated={str(exc2.value)!r}" + f"Error messages differ: padding_fail={str(exc1)!r}, truncated={str(exc2.value)!r}" ) From 91a97f1f166b4464d3a2333f97e101239074dc7e Mon Sep 17 00:00:00 2001 From: Kess Plasmeier Date: Wed, 13 May 2026 14:42:37 -0700 Subject: [PATCH 7/7] fix --- test/integration/test_i_security.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index 9a7f22d2..4d73a3ae 100644 --- a/test/integration/test_i_security.py +++ b/test/integration/test_i_security.py @@ -482,13 +482,13 @@ def test_wrong_key_and_tampered_ciphertext_produce_same_error(self): decryptor2.finalize(tampered) # Both MUST produce the same error message - assert str(exc1.value) == str(exc2.value), ( - f"Error messages differ: wrong_key={str(exc1.value)!r}, tampered={str(exc2.value)!r}" + assert str(exc1) == str(exc2.value), ( + f"Error messages differ: wrong_key={str(exc1)!r}, tampered={str(exc2.value)!r}" ) # Neither message should contain details about the underlying failure - assert "padding" not in str(exc1.value).lower(), ( - f"Error message leaks padding information: {str(exc1.value)!r}" + assert "padding" not in str(exc1).lower(), ( + f"Error message leaks padding information: {str(exc1)!r}" ) def test_truncated_ciphertext_produces_same_error(self):