diff --git a/.github/workflows/python-integ.yml b/.github/workflows/python-integ.yml index e2d710cc..6a23a7b3 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.10", "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 @@ -48,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-report=html:coverage-unit \ - --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-report=html:coverage-integ \ - --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,16 +68,16 @@ jobs: - name: Run examples run: make test-examples - - name: Generate coverage HTML report + - name: Upload unit test coverage report if: always() uses: actions/upload-artifact@v7 with: - name: coverage-unit + 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 + name: coverage-integ-py${{ matrix.python-version }}-${{ matrix.os }} path: coverage-integ/ 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 7fb8a58e..25318942 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", @@ -33,7 +33,7 @@ packages = ["src/s3_encryption"] [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py310" exclude = [".git", "__pycache__", "build", "dist"] [tool.ruff.lint] diff --git a/test/integration/test_i_security.py b/test/integration/test_i_security.py index 3d871e1f..4d73a3ae 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,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): @@ -495,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] @@ -508,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}, truncated={str(exc2.value)!r}" )