diff --git a/.duvet/.gitignore b/.duvet/.gitignore
new file mode 100644
index 00000000..93956e36
--- /dev/null
+++ b/.duvet/.gitignore
@@ -0,0 +1,3 @@
+reports/
+requirements/
+specification/
\ No newline at end of file
diff --git a/.duvet/config.toml b/.duvet/config.toml
new file mode 100644
index 00000000..cb7abf7f
--- /dev/null
+++ b/.duvet/config.toml
@@ -0,0 +1,43 @@
+'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json"
+
+[[source]]
+pattern = "src/**/*.py"
+type = "implementation"
+comment-style = { meta = "##=", content = "##%" }
+[[source]]
+pattern = "test/**/*.py"
+type = "test"
+comment-style = { meta = "##=", content = "##%" }
+[[source]]
+pattern = "compliance_exceptions/**/*.md"
+type = "exception"
+comment-style = { meta = "##=", content = "##%" }
+
+# Include required specifications here
+[[specification]]
+source = "specification/s3-encryption/materials/keyrings.md"
+[[specification]]
+source = "specification/s3-encryption/materials/s3-keyring.md"
+[[specification]]
+source = "specification/s3-encryption/materials/s3-kms-keyring.md"
+[[specification]]
+source = "specification/s3-encryption/client.md"
+[[specification]]
+source = "specification/s3-encryption/decryption.md"
+[[specification]]
+source = "specification/s3-encryption/encryption.md"
+[[specification]]
+source = "specification/s3-encryption/key-commitment.md"
+[[specification]]
+source = "specification/s3-encryption/key-derivation.md"
+[[specification]]
+source = "specification/s3-encryption/data-format/content-metadata.md"
+[[specification]]
+source = "specification/s3-encryption/data-format/metadata-strategy.md"
+
+[report.html]
+enabled = true
+
+# Enable snapshots to prevent requirement coverage regressions
+[report.snapshot]
+enabled = false
diff --git a/.github/workflows/all-ci.yml b/.github/workflows/all-ci.yml
new file mode 100644
index 00000000..d8c92bb9
--- /dev/null
+++ b/.github/workflows/all-ci.yml
@@ -0,0 +1,66 @@
+name: All CI
+
+on:
+ push:
+ branches: [ main, staging ]
+ pull_request:
+ workflow_dispatch:
+ inputs:
+ python-version:
+ description: 'Python version to use'
+ default: '3.11'
+ required: false
+ type: string
+
+jobs:
+ python-lint:
+ name: Lint
+ uses: ./.github/workflows/lint.yml
+
+ run-test-server:
+ permissions:
+ id-token: write
+ contents: read
+ name: Run TestServer Tests
+ uses: ./.github/workflows/test-server.yml
+ with:
+ python-version: ${{ inputs.python-version || '3.11' }}
+ secrets: inherit
+
+ python-integ:
+ permissions:
+ id-token: write
+ contents: read
+ name: Python Integration Tests
+ uses: ./.github/workflows/python-integ.yml
+ with:
+ python-version: ${{ inputs.python-version || '3.11' }}
+ secrets: inherit
+
+ python-perf:
+ permissions:
+ id-token: write
+ contents: read
+ name: Python Performance Tests
+ uses: ./.github/workflows/python-perf.yml
+ with:
+ python-version: ${{ inputs.python-version || '3.11' }}
+ secrets: inherit
+
+ run-duvet:
+ permissions:
+ id-token: write
+ contents: read
+ pages: write
+ name: Run Duvet
+ uses: ./.github/workflows/duvet.yml
+ secrets: inherit
+
+ run-duvet-test-server:
+ permissions:
+ id-token: write
+ contents: read
+ pages: write
+ name: Run Duvet
+ uses: ./.github/workflows/duvet-test-server.yml
+ secrets: inherit
diff --git a/.github/workflows/daily_ci.yml b/.github/workflows/daily_ci.yml
new file mode 100644
index 00000000..51e42fd4
--- /dev/null
+++ b/.github/workflows/daily_ci.yml
@@ -0,0 +1,50 @@
+name: Daily CI
+
+on:
+ schedule:
+ # 5 AM PST = 1 PM UTC, Monday–Friday
+ - cron: "0 13 * * 1-5"
+ workflow_dispatch:
+ inputs:
+ python-version:
+ description: 'Python version to use'
+ default: '3.11'
+ required: false
+ type: string
+
+jobs:
+ run-test-server:
+ permissions:
+ id-token: write
+ contents: read
+ name: Run TestServer Tests
+ uses: ./.github/workflows/test-server.yml
+ with:
+ python-version: ${{ inputs.python-version || '3.11' }}
+ secrets: inherit
+
+ python-integ:
+ permissions:
+ id-token: write
+ contents: read
+ name: Python Integration Tests
+ uses: ./.github/workflows/python-integ.yml
+ with:
+ python-version: ${{ inputs.python-version || '3.11' }}
+ secrets: inherit
+
+ notify:
+ needs:
+ [
+ run-test-server,
+ python-integ
+ ]
+ permissions:
+ id-token: write
+ contents: read
+ if: ${{ failure() }}
+ uses: aws/aws-cryptographic-material-providers-library/.github/workflows/slack-notification.yml@main
+ with:
+ message: "Daily CI failed on `${{ github.repository }}`. View run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ secrets:
+ SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_CI }}
diff --git a/.github/workflows/duvet-test-server.yml b/.github/workflows/duvet-test-server.yml
new file mode 100644
index 00000000..58ae19a2
--- /dev/null
+++ b/.github/workflows/duvet-test-server.yml
@@ -0,0 +1,121 @@
+name: Generate Duvet Report for TestServer
+
+on:
+ workflow_call:
+ # Optional inputs that can be provided when calling this workflow
+
+jobs:
+ duvet:
+ runs-on: macos-latest
+ permissions:
+ id-token: write
+ contents: read
+ pages: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ # There are a lot of submodules here
+ # This initializes the checkouts in parallel (--jobs)
+ # rather than in series the way actions/checkout@v6 does it.
+
+ - name: Get CPU count
+ id: cpu-count
+ run: echo "count=$(node -p 'require("os").cpus().length')" >> $GITHUB_OUTPUT
+
+ - name: Setup git submodules with PAT
+ run: |
+ git config --global url."https://github.com/".insteadOf "git@github.com:"
+ git config --global credential.helper store
+ echo "https://x-token-auth:${{ secrets.PAT_FOR_SPEC }}@github.com" > ~/.git-credentials
+
+ - name: Optimize git for performance
+ run: |
+ git config --global fetch.parallel ${{ steps.cpu-count.outputs.count }}
+ git config --global submodule.fetchJobs ${{ steps.cpu-count.outputs.count }}
+ git config --global remote.origin.tagOpt --no-tags
+
+ - name: Checkout submodules with --jobs
+ run: |
+ git submodule update --init --depth 1 --single-branch --jobs ${{ steps.cpu-count.outputs.count }} test-server/
+
+
+ - name: Checkout CPP code cpp-v3
+ uses: actions/checkout@v6
+ with:
+ submodules: recursive
+ repository: aws/aws-sdk-cpp
+ ref: main
+ path: test-server/cpp-v3-server/aws-sdk-cpp/
+
+ - name: Setup Rust toolchain
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: stable
+
+ - name: Build and install duvet
+ run: |
+ cargo install duvet --locked
+
+ - name: Run duvet
+ if: always()
+ run: cd test-server && make duvet
+
+ - name: Upload duvet reports
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: test-server-reports
+ include-hidden-files: true
+ path: test-server/*-server/.duvet/reports/report.html
+
+ - name: Generate compliance dashboard
+ if: always()
+ run: |
+ cd test-server/spec-compliance-dashboard
+ python generate_compliance_dashboard.py
+
+ - name: Create dashboard redirect index.html
+ if: always()
+ run: |
+ cat > test-server/index.html << 'EOF'
+
+
+
+
+
+ Redirecting to Compliance Dashboard...
+
+
+ Redirecting to Compliance Dashboard...
+
+
+ EOF
+
+ - name: Upload compliance dashboard
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: compliance-dashboard
+ include-hidden-files: true
+ path: |
+ test-server/spec-compliance-dashboard/compliance_homepage.html
+ test-server/*/compliance_summary_report.html
+ test-server/*/.duvet/reports/report.html
+ test-server/spec-compliance-dashboard/templates/*
+ test-server/index.html
+
+ - name: Setup Pages
+ if: always() && (github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/fireegg-test-servers') && github.event_name == 'push'
+ uses: actions/configure-pages@v5
+
+ - name: Upload Pages artifact
+ if: always() && (github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/fireegg-test-servers') && github.event_name == 'push'
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: test-server/
+
+ - name: Deploy to GitHub Pages
+ if: always() && (github.ref == 'refs/heads/staging' || github.ref == 'refs/heads/fireegg-test-servers') && github.event_name == 'push'
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/duvet.yml b/.github/workflows/duvet.yml
new file mode 100644
index 00000000..23bbe45a
--- /dev/null
+++ b/.github/workflows/duvet.yml
@@ -0,0 +1,40 @@
+name: duvet on the local S3EC-Python
+
+on:
+ workflow_call:
+ # Optional inputs that can be provided when calling this workflow
+
+jobs:
+ test:
+ runs-on: ubuntu-slim
+ permissions:
+ id-token: write
+ contents: read
+ pages: write
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Checkout specific specification
+ run: git submodule update --init --recursive specification
+
+ - name: Setup Rust toolchain
+ uses: actions-rust-lang/setup-rust-toolchain@v1
+ with:
+ toolchain: stable
+
+ - name: Install duvet
+ run: |
+ cargo install duvet --locked
+
+ - name: Run duvet
+ run: make duvet
+
+ - name: Upload duvet reports
+ uses: actions/upload-artifact@v7
+ with:
+ name: reports
+ include-hidden-files: true
+ path: .duvet/reports/report.html
+
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 00000000..a1ef5e2d
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,29 @@
+name: Lint
+
+on:
+ push:
+ branches: [ main ]
+ workflow_call:
+ workflow_dispatch:
+
+jobs:
+ lint:
+ runs-on: macos-15
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+
+ - name: Install Uv
+ run: pip install uv
+
+ - name: Install dependencies and run linting
+ run: |
+ make install
+ make format-check
+ make lint
diff --git a/.github/workflows/python-integ.yml b/.github/workflows/python-integ.yml
new file mode 100644
index 00000000..7c22d3e4
--- /dev/null
+++ b/.github/workflows/python-integ.yml
@@ -0,0 +1,84 @@
+name: Python Integration Tests
+
+on:
+ workflow_call:
+ inputs:
+ python-version:
+ description: "Python version to use (ignored when matrix is used)"
+ default: "3.11"
+ required: false
+ type: string
+
+jobs:
+ python-integ:
+ 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
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ submodules: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ matrix.python-version }}
+ allow-prereleases: true
+
+ - name: Cache uv dependencies
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/uv
+ 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
+ run: pip install uv
+
+ - name: Install dependencies
+ run: make install
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v6
+ with:
+ special-characters-workaround: "true"
+ role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role
+ 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
+
+ - 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
+ 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: 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/
diff --git a/.github/workflows/python-perf.yml b/.github/workflows/python-perf.yml
new file mode 100644
index 00000000..38bddb56
--- /dev/null
+++ b/.github/workflows/python-perf.yml
@@ -0,0 +1,67 @@
+name: Python Performance Tests
+
+on:
+ workflow_call:
+ inputs:
+ python-version:
+ description: "Python version to use"
+ default: "3.11"
+ required: false
+ type: string
+
+jobs:
+ python-perf:
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ submodules: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ inputs.python-version || '3.11' }}
+
+ - name: Cache uv dependencies
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/uv
+ key: ${{ runner.os }}-uv-${{ hashFiles('**/pyproject.toml') }}
+ restore-keys: |
+ ${{ runner.os }}-uv-
+
+ - name: Install Uv
+ run: pip install uv
+
+ - name: Install dependencies
+ run: make install
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v6
+ with:
+ special-characters-workaround: "true"
+ role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role
+ aws-region: us-west-2
+
+ - name: Run performance tests
+ run: make test-perf
+ env:
+ CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }}
+ CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }}
+ PERF_NUM_ROUNDS: "10"
+
+ - name: Generate performance HTML report
+ if: always()
+ run: uv run python test/performance/generate_report.py
+
+ - name: Upload performance report
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: performance-report
+ path: perf-results/
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..f43a2abc
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,232 @@
+name: Release to PyPI
+
+on:
+ workflow_dispatch:
+ inputs:
+ version_override:
+ description: "Manual version override (leave empty to use semantic-release)"
+ required: false
+ type: string
+ dry_run:
+ description: "Dry run (determine version only, do not publish)"
+ required: false
+ type: boolean
+ default: false
+
+jobs:
+ determine-version:
+ name: Determine Version
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ persist-credentials: true
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "26"
+ - name: Install semantic-release
+ run: npm install -g semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/changelog @semantic-release/exec @semantic-release/git @semantic-release/github conventional-changelog-conventionalcommits
+ - name: Determine next version
+ id: version
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ if [ -n "${{ inputs.version_override }}" ]; then
+ echo "version=${{ inputs.version_override }}" >> "$GITHUB_OUTPUT"
+ echo "Using manual override: ${{ inputs.version_override }}"
+ else
+ # Run semantic-release in dry-run to get the next version
+ VERSION=$(npx semantic-release --dry-run 2>&1 | grep -oP 'The next release version is \K[0-9]+\.[0-9]+\.[0-9]+' || true)
+ if [ -z "$VERSION" ]; then
+ echo "No release needed based on commits"
+ echo "version=" >> "$GITHUB_OUTPUT"
+ else
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "Semantic release determined version: $VERSION"
+ fi
+ fi
+
+ test:
+ name: Run Tests
+ needs: determine-version
+ if: needs.determine-version.outputs.version != ''
+ uses: ./.github/workflows/python-integ.yml
+ permissions:
+ id-token: write
+ contents: read
+ secrets: inherit
+
+ build:
+ name: Build Package
+ needs: [determine-version, test]
+ if: needs.determine-version.outputs.version != ''
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.10"
+ - run: pip install build
+ - name: Set version in pyproject.toml
+ run: sed -i "s/^version = .*/version = \"${{ needs.determine-version.outputs.version }}\"/" pyproject.toml
+ - name: Verify version
+ run: |
+ grep "version = \"${{ needs.determine-version.outputs.version }}\"" pyproject.toml
+ - run: python -m build
+ - uses: actions/upload-artifact@v7
+ with:
+ name: dist
+ path: dist/
+
+ publish-testpypi:
+ name: Publish to TestPyPI
+ if: ${{ !inputs.dry_run && needs.determine-version.outputs.version != '' }}
+ needs: [determine-version, build]
+ runs-on: ubuntu-latest
+ environment: testpypi
+ permissions:
+ id-token: write
+ steps:
+ - uses: actions/download-artifact@v7
+ with:
+ name: dist
+ path: dist/
+ - uses: pypa/gh-action-pypi-publish@release/v1
+ with:
+ repository-url: https://test.pypi.org/legacy/
+ skip-existing: true
+
+ validate-testpypi:
+ name: Validate TestPyPI Package
+ needs: [determine-version, publish-testpypi]
+ if: needs.determine-version.outputs.version != ''
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ sparse-checkout: release-validation
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.10"
+ - uses: aws-actions/configure-aws-credentials@v6
+ with:
+ special-characters-workaround: "true"
+ role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role
+ aws-region: us-west-2
+ - name: Wait for TestPyPI availability
+ run: |
+ for i in $(seq 1 30); do
+ if pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ "amazon-s3-encryption-client-python==${{ needs.determine-version.outputs.version }}" 2>/dev/null; then
+ echo "Package available on TestPyPI"
+ exit 0
+ fi
+ echo "Waiting for package to appear on TestPyPI ($i/30)..."
+ sleep 10
+ done
+ echo "Package not found on TestPyPI after 5 minutes"
+ exit 1
+ - name: Run validation
+ run: python release-validation/validate.py
+ env:
+ CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }}
+ CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }}
+
+ publish-pypi:
+ name: Publish to PyPI
+ needs: [determine-version, validate-testpypi]
+ if: needs.determine-version.outputs.version != ''
+ runs-on: ubuntu-latest
+ environment: pypi
+ permissions:
+ id-token: write
+ steps:
+ - uses: actions/download-artifact@v7
+ with:
+ name: dist
+ path: dist/
+ - uses: pypa/gh-action-pypi-publish@release/v1
+
+ validate-pypi:
+ name: Validate PyPI Package
+ needs: [determine-version, publish-pypi]
+ if: needs.determine-version.outputs.version != ''
+ runs-on: ubuntu-latest
+ permissions:
+ id-token: write
+ contents: read
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ sparse-checkout: release-validation
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.10"
+ - uses: aws-actions/configure-aws-credentials@v6
+ with:
+ special-characters-workaround: "true"
+ role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role
+ aws-region: us-west-2
+ - name: Wait for PyPI availability
+ run: |
+ for i in $(seq 1 30); do
+ if pip install "amazon-s3-encryption-client-python==${{ needs.determine-version.outputs.version }}" 2>/dev/null; then
+ echo "Package available on PyPI"
+ exit 0
+ fi
+ echo "Waiting for package to appear on PyPI ($i/30)..."
+ sleep 10
+ done
+ echo "Package not found on PyPI after 5 minutes"
+ exit 1
+ - name: Run validation
+ run: python release-validation/validate.py
+ env:
+ CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }}
+ CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }}
+
+ create-release:
+ name: Create GitHub Release
+ if: ${{ !inputs.dry_run && needs.determine-version.outputs.version != '' }}
+ needs: [determine-version, validate-pypi]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ persist-credentials: true
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "26"
+ - name: Install semantic-release
+ run: npm install -g semantic-release @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/changelog @semantic-release/exec @semantic-release/git @semantic-release/github conventional-changelog-conventionalcommits
+ - name: Create release
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ VERSION="${{ needs.determine-version.outputs.version }}"
+ if [ -n "${{ inputs.version_override }}" ]; then
+ # Manual override: commit the version bump and create a GitHub release
+ sed -i "s/^version = .*/version = \"${VERSION}\"/" pyproject.toml
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add pyproject.toml
+ git commit -m "chore(release): ${VERSION} [skip ci]" || true
+ git tag "v${VERSION}"
+ git push --follow-tags
+ gh release create "v${VERSION}" \
+ --title "v${VERSION}" \
+ --generate-notes \
+ --draft
+ else
+ npx semantic-release
+ fi
diff --git a/.github/workflows/test-server.yml b/.github/workflows/test-server.yml
new file mode 100644
index 00000000..b60c2167
--- /dev/null
+++ b/.github/workflows/test-server.yml
@@ -0,0 +1,151 @@
+name: Run TestServer Tests
+
+on:
+ workflow_call:
+ # Optional inputs that can be provided when calling this workflow
+ inputs:
+ python-version:
+ description: "Python version to use"
+ default: "3.11"
+ required: false
+ type: string
+
+jobs:
+ test-server:
+ runs-on: macos-14-large
+ permissions:
+ id-token: write
+ contents: read
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+ with:
+ submodules: false
+ token: ${{ secrets.PAT_FOR_SPEC }}
+
+ # There are a lot of submodules here
+ # This initializes the checkouts in parallel (--jobs)
+ # rather than in series the way actions/checkout@v6 does it.
+
+ - name: Get CPU count
+ id: cpu-count
+ run: echo "count=$(node -p 'require("os").cpus().length')" >> $GITHUB_OUTPUT
+
+ - name: Setup git submodules with PAT
+ run: |
+ git config --global url."https://github.com/".insteadOf "git@github.com:"
+ git config --global credential.helper store
+ echo "https://x-token-auth:${{ secrets.PAT_FOR_SPEC }}@github.com" > ~/.git-credentials
+
+ - name: Optimize git for performance
+ run: |
+ git config --global fetch.parallel ${{ steps.cpu-count.outputs.count }}
+ git config --global submodule.fetchJobs ${{ steps.cpu-count.outputs.count }}
+ git config --global remote.origin.tagOpt --no-tags
+
+ - name: Checkout submodules with --jobs
+ run: |
+ git submodule update --init --depth 1 --single-branch --jobs ${{ steps.cpu-count.outputs.count }} test-server/
+
+ - name: Update cpp submodules recursively with --jobs
+ run: |
+ git submodule update --init --recursive \
+ --depth 1 --single-branch \
+ --jobs ${{ steps.cpu-count.outputs.count }} \
+ --force \
+ test-server/cpp-v2-transition-server/aws-sdk-cpp \
+ test-server/cpp-v3-server/aws-sdk-cpp
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.4.7"
+ bundler-cache: true
+
+ - name: Set up PHP with Composer
+ uses: shivammathur/setup-php@verbose
+ with:
+ php-version: "8.1"
+
+ - name: Install PHP V2 Transition dependencies
+ working-directory: ./test-server/php-v2-transition-server
+ shell: bash
+ run: composer install
+
+ - name: Install PHP V3 dependencies
+ working-directory: ./test-server/php-v3-server
+ shell: bash
+ run: composer install
+
+ - name: Install Go
+ uses: actions/setup-go@v5
+ with:
+ go-version: 1.25
+
+ - name: Install C++ dependencies
+ run: |
+ brew install libmicrohttpd nlohmann-json ossp-uuid
+
+ # Cache Gradle dependencies and build outputs
+ - name: Cache Gradle packages
+ uses: actions/cache@v5
+ with:
+ path: |
+ ~/.gradle/caches
+ ~/.gradle/wrapper
+ test-server/java-tests/.gradle
+ key: ${{ runner.os }}-gradle-${{ hashFiles('test-server/java-tests/**/gradle-wrapper.properties', 'test-server/java-tests/**/*.gradle*') }}
+ restore-keys: |
+ ${{ runner.os }}-gradle-
+
+ - name: Configure AWS credentials
+ uses: aws-actions/configure-aws-credentials@v6
+ with:
+ special-characters-workaround: "true"
+ role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role
+ aws-region: us-west-2
+
+ - name: Build the servers
+ run: cd test-server && make build-all-servers
+ env:
+ MAKEFLAGS: -j${{ steps.cpu-count.outputs.count }}
+ AWS_REGION: us-west-2
+
+ - name: Start the servers
+ run: cd test-server && make start-all-servers
+ env:
+ AWS_REGION: us-west-2
+ TEST_SERVER_S3_BUCKET: ${{ vars.TEST_SERVER_S3_BUCKET }}
+ TEST_SERVER_KMS_KEY_ARN: ${{ vars.TEST_SERVER_KMS_KEY_ARN }}
+
+ - name: Wait for servers to start
+ run: cd test-server && make wait-all-servers
+ env:
+ MAKEFLAGS: -j${{ steps.cpu-count.outputs.count }}
+
+ - name: Run run-tests
+ run: cd test-server && make test-servers-run-tests
+ env:
+ AWS_REGION: us-west-2
+ TEST_SERVER_S3_BUCKET: ${{ vars.TEST_SERVER_S3_BUCKET }}
+ TEST_SERVER_KMS_KEY_ARN: ${{ vars.TEST_SERVER_KMS_KEY_ARN }}
+ GRADLE_OPTS: "-Dorg.gradle.daemon=true -Dorg.gradle.parallel=true -Dorg.gradle.caching=true"
+
+ - name: Upload server logs
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: server-logs
+ path: |
+ test-server/*/server.log
+
+ - name: Stop the servers
+ run: cd test-server && make test-servers-stop
+
+ - name: Upload results
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: results
+ path: test-server/java-tests/build/reports/tests/integ
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..39fc3914
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,63 @@
+# Exclude all pycache directories and bytecode
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+
+# Distribution / packaging
+dist/
+build/
+bin/
+*.egg-info/
+
+# Uv
+.uv/
+uv.lock
+
+# Gradle
+.gradle/
+gradle-app.setting
+
+# IDE - IntelliJ IDEA
+.idea/
+*.iml
+*.iws
+*.ipr
+
+# IDE - VS Code
+.vscode/
+.settings/
+.project
+.classpath
+
+# Compiled class files
+*.class
+
+# Log files
+*.log
+
+# Package files
+*.jar
+!gradle/wrapper/gradle-wrapper.jar
+!**/gradle/wrapper/gradle-wrapper.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+*.hprof
+.kotlin/
+
+.DS_Store
+smithy-java-core/out
+
+# test server
+*.pid
+.coverage
+coverage-report/
+perf-results/
+
+# Sphinx docs build output
+docs/_build/
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..162cd457
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,52 @@
+[submodule "test-server/ruby-v2-server/local-ruby-sdk"]
+ path = test-server/ruby-v2-server/local-ruby-sdk
+ url = git@github.com:aws/aws-sdk-ruby.git
+ branch = version-3
+[submodule "test-server/ruby-v3-server/local-ruby-sdk"]
+ path = test-server/ruby-v3-server/local-ruby-sdk
+ url = git@github.com:aws/aws-sdk-ruby.git
+ branch = version-3
+[submodule "test-server/php-v3-server/local-php-sdk"]
+ path = test-server/php-v3-server/local-php-sdk
+ url = git@github.com:aws/aws-sdk-php.git
+ branch = master
+[submodule "test-server/go-v4-server/local-go-s3ec"]
+ path = test-server/go-v4-server/local-go-s3ec
+ url = https://github.com/aws/amazon-s3-encryption-client-go
+ branch = main
+[submodule "test-server/java-v3-transition-server/s3ec-staging"]
+ path = test-server/java-v3-transition-server/s3ec-staging
+ url = git@github.com:aws/amazon-s3-encryption-client-java.git
+ branch = main-3.x
+[submodule "test-server/java-v4-server/s3ec-staging"]
+ path = test-server/java-v4-server/s3ec-staging
+ url = git@github.com:aws/amazon-s3-encryption-client-java.git
+ branch = main
+[submodule "test-server/specification"]
+ path = test-server/specification
+ url = git@github.com:awslabs/private-aws-encryption-sdk-specification-staging.git
+ branch = fire-egg-staging
+[submodule "test-server/net-v4-server/s3ec-net-v4-improved"]
+ path = test-server/net-v4-server/s3ec-net-v4-improved
+ url = https://github.com/aws/amazon-s3-encryption-client-dotnet.git
+ branch = main
+[submodule "test-server/go-v3-transition-server/local-go-s3ec"]
+ path = test-server/go-v3-transition-server/local-go-s3ec
+ url = https://github.com/aws/amazon-s3-encryption-client-go
+ branch = main
+[submodule "test-server/net-v3-transition-server/s3ec-v3-transition-branch"]
+ path = test-server/net-v3-transition-server/s3ec-v3-transition-branch
+ url = https://github.com/aws/amazon-s3-encryption-client-dotnet.git
+ branch = v4sdk-development
+[submodule "test-server/cpp-v2-transition-server/aws-sdk-cpp"]
+ path = test-server/cpp-v2-transition-server/aws-sdk-cpp
+ url = git@github.com:aws/aws-sdk-cpp.git
+ branch = main
+[submodule "test-server/cpp-v3-server/aws-sdk-cpp"]
+ path = test-server/cpp-v3-server/aws-sdk-cpp
+ url = git@github.com:aws/aws-sdk-cpp.git
+ branch = main
+[submodule "specification"]
+ path = specification
+ url = https://github.com/awslabs/aws-encryption-sdk-specification.git
+ branch = tonyknap/s3ec-v3.0.1-candidate
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
new file mode 100644
index 00000000..26768c7d
--- /dev/null
+++ b/.readthedocs.yaml
@@ -0,0 +1,16 @@
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.11"
+
+sphinx:
+ configuration: docs/conf.py
+
+python:
+ install:
+ - method: pip
+ path: .
+ extra_requirements:
+ - docs
diff --git a/.releaserc.cjs b/.releaserc.cjs
new file mode 100644
index 00000000..bdd99b04
--- /dev/null
+++ b/.releaserc.cjs
@@ -0,0 +1,70 @@
+/**
+ * Semantic Release configuration for Amazon S3 Encryption Client for Python.
+ *
+ * Determines the next version from conventional commits, updates pyproject.toml,
+ * generates release notes, and creates a GitHub release.
+ */
+module.exports = {
+ branches: ["main"],
+ plugins: [
+ [
+ "@semantic-release/commit-analyzer",
+ {
+ preset: "conventionalcommits",
+ releaseRules: [
+ { type: "feat", release: "minor" },
+ { type: "fix", release: "patch" },
+ { type: "perf", release: "patch" },
+ { type: "revert", release: "patch" },
+ { breaking: true, release: "major" },
+ ],
+ },
+ ],
+ [
+ "@semantic-release/release-notes-generator",
+ {
+ preset: "conventionalcommits",
+ presetConfig: {
+ types: [
+ { type: "feat", section: "Features" },
+ { type: "fix", section: "Bug Fixes" },
+ { type: "perf", section: "Performance" },
+ { type: "revert", section: "Reverts" },
+ { type: "docs", section: "Documentation", hidden: false },
+ { type: "chore", section: "Maintenance", hidden: false },
+ { type: "refactor", section: "Refactoring", hidden: false },
+ { type: "test", section: "Tests", hidden: true },
+ { type: "ci", section: "CI", hidden: true },
+ ],
+ },
+ },
+ ],
+ [
+ "@semantic-release/exec",
+ {
+ prepareCmd:
+ 'sed -i "s/^version = .*/version = \\"${nextRelease.version}\\"/" pyproject.toml',
+ },
+ ],
+ [
+ "@semantic-release/changelog",
+ {
+ changelogFile: "CHANGELOG.md",
+ },
+ ],
+ [
+ "@semantic-release/git",
+ {
+ assets: ["pyproject.toml", "CHANGELOG.md"],
+ message:
+ "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}",
+ },
+ ],
+ [
+ "@semantic-release/github",
+ {
+ draftRelease: true,
+ },
+ ],
+ ],
+};
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..e788379b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,67 @@
+.PHONY: lint format format-check test test-unit test-integration test-perf install docs
+
+# Default target
+all: lint test duvet
+
+# Install dependencies
+install:
+ uv venv
+ uv pip install -e ".[dev,test]"
+
+# Run linting checks
+lint:
+ uv run ruff check src/
+ uv run ruff check test/ || true
+
+# Check formatting (no changes, just verify)
+format-check:
+ uv run ruff format --check src/ test/
+
+# Format code
+format:
+ uv run ruff format src/ test/
+ uv run ruff check --fix src/ test/
+
+# Run all tests with combined coverage
+test: test-unit test-integration test-examples
+
+# Run unit tests with coverage
+test-unit:
+ uv run pytest test/ --ignore=test/integration/ --ignore=test/performance/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=89
+
+# Run integration tests with separate coverage
+test-integration:
+ uv run pytest test/integration/ --verbose --cov=src/s3_encryption --cov-report=term-missing --cov-fail-under=83
+
+# Run performance tests
+test-perf:
+ uv run pytest test/performance/ --verbose -x
+
+test-examples:
+ uv run pytest examples/test/ -v
+
+# Clean up cache files
+clean:
+ find . -type d -name __pycache__ -exec rm -rf {} +
+ find . -type d -name .pytest_cache -exec rm -rf {} +
+ find . -type d -name .coverage -exec rm -rf {} +
+ find . -type f -name "*.pyc" -delete
+ rm -rf .duvet/reports/ .duvet/requirements/
+
+duvet: | clean duvet-report
+
+duvet-report:
+ duvet report
+
+duvet-view-report-mac:
+ open .duvet/reports/report.html
+
+
+# Build docs locally
+docs:
+ uv pip install -e ".[docs]"
+ uv run sphinx-build -b html docs/ docs/_build/html
+ @echo "Docs built at docs/_build/html/index.html"
+
+docs-open: docs
+ open docs/_build/html/index.html
diff --git a/NOTICE b/NOTICE
index 616fc588..3d299504 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1 +1,2 @@
+Amazon S3 Encryption Client for Python
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
diff --git a/README.md b/README.md
index 847260ca..70866688 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,102 @@
-## My Project
+# Amazon S3 Encryption Client for Python
-TODO: Fill this README out!
+This library provides an S3 client that supports client-side encryption. For more information and detailed instructions for how to use this library, refer to the [Amazon S3 Encryption Client Developer Guide](https://docs.aws.amazon.com/amazon-s3-encryption-client/latest/developerguide/python.html).
-Be sure to:
+## Getting Started
-* Change the title in this README
-* Edit your repository description on GitHub
+Requires Python 3.10 or greater. An AWS account is required; standard S3 and KMS charges apply.
-## Security
+The S3 Encryption Client wraps a standard boto3 S3 client and uses a KMS keyring to manage data key encryption. Objects are encrypted before upload and decrypted after download transparently. By default, the client uses AES-GCM with key commitment for content encryption.
-See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.
+```python
+import boto3
+from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig
+from s3_encryption.materials.kms_keyring import KmsKeyring
-## License
+kms_client = boto3.client("kms", region_name="us-west-2")
+keyring = KmsKeyring(kms_client, "arn:aws:kms:us-west-2:123456789012:alias/my-key")
-This project is licensed under the Apache-2.0 License.
+s3_client = boto3.client("s3")
+config = S3EncryptionClientConfig(keyring=keyring)
+s3ec = S3EncryptionClient(s3_client, config)
+# Encrypt and upload
+s3ec.put_object(Bucket="my-bucket", Key="my-object", Body=b"secret data")
+
+# Download and decrypt
+response = s3ec.get_object(Bucket="my-bucket", Key="my-object")
+plaintext = response["Body"].read()
+```
+
+## Development
+
+### Prerequisites
+
+- Python 3.10 or higher
+- [uv](https://github.com/astral-sh/uv) for package and project management
+
+### Setup
+
+Install dependencies:
+
+```bash
+make install
+```
+
+### Testing
+
+Run all tests (unit + integration + examples):
+
+```bash
+make test
+```
+
+Run unit tests only:
+
+```bash
+make test-unit
+```
+
+Run integration tests only:
+
+```bash
+make test-integration
+```
+
+### Code Quality
+
+This project uses [Ruff](https://docs.astral.sh/ruff/) for linting and formatting.
+
+Check formatting:
+
+```bash
+make format-check
+```
+
+Run linter:
+
+```bash
+make lint
+```
+
+Format code and auto-fix lint issues:
+
+```bash
+make format
+```
+
+### Integration Test Resources
+
+Integration tests require AWS credentials and the following resources. The tests use environment variables to override CI defaults:
+
+| Variable | Description | Default |
+|----------|-------------|---------|
+| `CI_S3_BUCKET` | S3 bucket for read/write tests | `s3ec-python-github-test-bucket` |
+| `CI_AWS_REGION` | Primary AWS region | `us-west-2` |
+| `CI_KMS_KEY_ALIAS` | KMS key ARN or alias for encryption | `arn:aws:kms:us-west-2:370957321024:alias/S3EC-Python-Github-KMS-Key` |
+| `CI_MRK_KEY_ID_PRIMARY` | Multi-region key ARN (primary region) | `arn:aws:kms:us-west-2:370957321024:key/mrk-cea4cf67c6a046ba829f61f69db5c191` |
+| `CI_MRK_KEY_ID_REPLICA` | Multi-region key ARN (replica region) | `arn:aws:kms:us-east-1:370957321024:key/mrk-cea4cf67c6a046ba829f61f69db5c191` |
+| `CI_S3_STATIC_TEST_BUCKET` | Bucket with pre-existing test objects for instruction file tests | `s3ec-static-test-objects` |
+| `CI_KMS_KEY_STATIC_TESTS` | KMS key used for static test objects | `arn:aws:kms:us-west-2:370957321024:key/a3889cd9-99eb-4138-a93a-aea9d52ec2ef` |
+
+To run integration tests locally, configure AWS credentials with access to these resources (or your own equivalents) and set the environment variables accordingly.
diff --git a/SUPPORT_POLICY.rst b/SUPPORT_POLICY.rst
new file mode 100644
index 00000000..5920fb8b
--- /dev/null
+++ b/SUPPORT_POLICY.rst
@@ -0,0 +1,29 @@
+Overview
+========
+This page describes the support policy for the Amazon S3 Encryption Client. We regularly provide the Amazon S3 Encryption Client with updates that may contain support for new or updated APIs, new features, enhancements, bug fixes, security patches, or documentation updates. Updates may also address changes with dependencies, language runtimes, and operating systems.
+
+We recommend users to stay up-to-date with Amazon S3 Encryption Client releases to keep up with the latest features, security updates, and underlying dependencies. Continued use of an unsupported client version is not recommended and is done at the user’s discretion
+
+
+Major Version Lifecycle
+========================
+The Amazon S3 Encryption Client follows the same major version lifecycle as the AWS SDK. For details on this lifecycle, see `AWS SDKs and Tools Maintenance Policy`_.
+
+Version Support Matrix
+======================
+This table describes the current support status of each major version of the Amazon S3 Encryption Client for Python. It also shows the next status each major version will transition to, and the date at which that transition will happen.
+
+.. list-table::
+ :widths: 30 50 50 50
+ :header-rows: 1
+
+ * - Major version
+ - Current status
+ - Next status
+ - Next status date
+ * - 4.x
+ - Generally Available
+ -
+ -
+
+.. _AWS SDKs and Tools Maintenance Policy: https://docs.aws.amazon.com/sdkref/latest/guide/maint-policy.html#version-life-cycle
diff --git a/cdk/.gitignore b/cdk/.gitignore
new file mode 100644
index 00000000..f60797b6
--- /dev/null
+++ b/cdk/.gitignore
@@ -0,0 +1,8 @@
+*.js
+!jest.config.js
+*.d.ts
+node_modules
+
+# CDK asset staging directory
+.cdk.staging
+cdk.out
diff --git a/cdk/.npmignore b/cdk/.npmignore
new file mode 100644
index 00000000..c1d6d45d
--- /dev/null
+++ b/cdk/.npmignore
@@ -0,0 +1,6 @@
+*.ts
+!*.d.ts
+
+# CDK asset staging directory
+.cdk.staging
+cdk.out
diff --git a/cdk/README.md b/cdk/README.md
new file mode 100644
index 00000000..320efc02
--- /dev/null
+++ b/cdk/README.md
@@ -0,0 +1,14 @@
+# Welcome to your CDK TypeScript project
+
+This is a blank project for CDK development with TypeScript.
+
+The `cdk.json` file tells the CDK Toolkit how to execute your app.
+
+## Useful commands
+
+* `npm run build` compile typescript to js
+* `npm run watch` watch for changes and compile
+* `npm run test` perform the jest unit tests
+* `cdk deploy` deploy this stack to your default AWS account/region
+* `cdk diff` compare deployed stack with current state
+* `cdk synth` emits the synthesized CloudFormation template
diff --git a/cdk/bin/cdk.ts b/cdk/bin/cdk.ts
new file mode 100644
index 00000000..08214db5
--- /dev/null
+++ b/cdk/bin/cdk.ts
@@ -0,0 +1,7 @@
+#!/usr/bin/env node
+import 'source-map-support/register';
+import * as cdk from 'aws-cdk-lib';
+import { S3ECPythonGithub } from '../lib/cdk-stack';
+
+const app = new cdk.App();
+new S3ECPythonGithub(app, 'S3ECPythonGithub');
diff --git a/cdk/cdk.json b/cdk/cdk.json
new file mode 100644
index 00000000..a7260af7
--- /dev/null
+++ b/cdk/cdk.json
@@ -0,0 +1,57 @@
+{
+ "app": "npx ts-node --prefer-ts-exts bin/cdk.ts",
+ "watch": {
+ "include": [
+ "**"
+ ],
+ "exclude": [
+ "README.md",
+ "cdk*.json",
+ "**/*.d.ts",
+ "**/*.js",
+ "tsconfig.json",
+ "package*.json",
+ "yarn.lock",
+ "node_modules",
+ "test"
+ ]
+ },
+ "context": {
+ "@aws-cdk/aws-lambda:recognizeLayerVersion": true,
+ "@aws-cdk/core:checkSecretUsage": true,
+ "@aws-cdk/core:target-partitions": [
+ "aws",
+ "aws-cn"
+ ],
+ "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
+ "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
+ "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
+ "@aws-cdk/aws-iam:minimizePolicies": true,
+ "@aws-cdk/core:validateSnapshotRemovalPolicy": true,
+ "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
+ "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
+ "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
+ "@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
+ "@aws-cdk/core:enablePartitionLiterals": true,
+ "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
+ "@aws-cdk/aws-iam:standardizedServicePrincipals": true,
+ "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
+ "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
+ "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
+ "@aws-cdk/aws-route53-patters:useCertificate": true,
+ "@aws-cdk/customresources:installLatestAwsSdkDefault": false,
+ "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
+ "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
+ "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
+ "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
+ "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
+ "@aws-cdk/aws-redshift:columnId": true,
+ "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
+ "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
+ "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
+ "@aws-cdk/aws-kms:aliasNameRef": true,
+ "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
+ "@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
+ "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true
+ }
+}
diff --git a/cdk/jest.config.js b/cdk/jest.config.js
new file mode 100644
index 00000000..08263b89
--- /dev/null
+++ b/cdk/jest.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ testEnvironment: 'node',
+ roots: ['/test'],
+ testMatch: ['**/*.test.ts'],
+ transform: {
+ '^.+\\.tsx?$': 'ts-jest'
+ }
+};
diff --git a/cdk/lib/cdk-stack.ts b/cdk/lib/cdk-stack.ts
new file mode 100644
index 00000000..329e16ca
--- /dev/null
+++ b/cdk/lib/cdk-stack.ts
@@ -0,0 +1,241 @@
+import * as cdk from 'aws-cdk-lib';
+import { Construct } from 'constructs';
+import {
+ Alias,
+ Key
+} from "aws-cdk-lib/aws-kms";
+import {
+ Effect,
+ Role,
+ PolicyDocument,
+ PolicyStatement,
+ FederatedPrincipal,
+ ArnPrincipal,
+ CompositePrincipal,
+ ManagedPolicy,
+} from "aws-cdk-lib/aws-iam";
+import {
+ BlockPublicAccess,
+ BlockPublicAccessOptions,
+ Bucket,
+} from 'aws-cdk-lib/aws-s3';
+
+export class S3ECPythonGithub extends cdk.Stack {
+ constructor(scope: Construct, id: string, props?: cdk.StackProps) {
+ super(scope, id, props);
+
+ // KMS Keys - default policy is fine,
+ // we use IAM to manage key permissions
+ const S3ECGithubKMSKey = new Key(
+ this,
+ "S3ECGithubKMSKey",
+ {
+ enableKeyRotation: true,
+ description: "KMS Key for GitHub Action Workflow",
+ }
+ )
+
+ // KMS alias
+ const S3ECGithubKMSKeyAlias = new Alias(
+ this,
+ "S3ECGithubKMSKeyAlias",
+ {
+ aliasName: "alias/S3EC-Python-Github-KMS-Key",
+ targetKey: S3ECGithubKMSKey
+ }
+ )
+
+ // KMS Key for test-server
+ const S3ECTestServerKMSKey = new Key(
+ this,
+ "S3ECTestServerKMSKey",
+ {
+ enableKeyRotation: true,
+ description: "KMS Key for Test Server GitHub Action Workflow",
+ }
+ )
+
+ // KMS alias for test-server
+ const S3ECTestServerKMSKeyAlias = new Alias(
+ this,
+ "S3ECTestServerKMSKeyAlias",
+ {
+ aliasName: "alias/S3EC-Test-Server-Github-KMS-Key",
+ targetKey: S3ECTestServerKMSKey
+ }
+ )
+
+ // 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,
+ blockPublicPolicy: false,
+ ignorePublicAcls: false,
+ restrictPublicBuckets: false
+ }
+ const S3ECGithubTestS3Bucket = new Bucket(
+ this,
+ "S3ECGithubTestS3Bucket",
+ {
+ bucketName: "s3ec-python-github-test-bucket",
+ blockPublicAccess: new BlockPublicAccess(AccessConfiguration)
+ }
+ )
+
+ // New bucket for test-server
+ const S3ECTestServerGithubBucket = new Bucket(
+ this,
+ "S3ECTestServerGithubBucket",
+ {
+ bucketName: "s3ec-test-server-github-bucket",
+ blockPublicAccess: new BlockPublicAccess(AccessConfiguration)
+ }
+ )
+
+ // New bucket for static test objects
+ const S3ECStaticTestObjectsBucket = new Bucket(
+ this,
+ "S3ECStaticTestObjectsBucket",
+ {
+ bucketName: "s3ec-static-test-objects",
+ blockPublicAccess: new BlockPublicAccess(AccessConfiguration)
+ }
+ )
+
+ // S3 bucket policy
+ const S3ECGithubS3BucketPolicy = new ManagedPolicy(
+ this,
+ "S3EC-Python-Github-S3-Bucket-Policy",
+ {
+ document: new PolicyDocument({
+ statements: [
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: [
+ "s3:HeadObject", // Only get object metadata
+ "s3:PutObject",
+ "s3:GetObject",
+ "s3:DeleteObject",
+ "s3:DeleteObjectVersion" // For S3EC-NET repo
+ ],
+ resources: [
+ S3ECGithubTestS3Bucket.bucketArn + "/*", // object-level permissions need this extra path
+ S3ECTestServerGithubBucket.bucketArn + "/*", // Add permissions for the new test-server bucket
+ S3ECStaticTestObjectsBucket.bucketArn + "/*", // Add permissions for static test objects bucket
+ "arn:aws:s3:::aws-net-sdk-*/*" // permission for object inside S3EC .net bucket. For S3EC-NET repo
+ ],
+ }),
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: [
+ "s3:CreateBucket", // For S3EC-NET repo
+ "s3:DeleteBucket", // For S3EC-NET repo
+ "s3:ListBucket",
+ "s3:ListBucketVersions", // For S3EC-NET repo
+ "s3:GetBucketAcl" // For S3EC-NET repo
+ ],
+ resources: [
+ S3ECGithubTestS3Bucket.bucketArn,
+ S3ECTestServerGithubBucket.bucketArn, // Add permissions for the new test-server bucket
+ S3ECStaticTestObjectsBucket.bucketArn, // Add permissions for static test objects bucket
+ "arn:aws:s3:::aws-net-sdk-*", // permission for S3EC .net bucket. For S3EC-NET repo
+ ],
+ }),
+ ]
+ }),
+ }
+ );
+
+ // KMS key policy
+ const S3ECGithubKMSKeyPolicy = new ManagedPolicy(
+ this,
+ "S3EC-Python-Github-KMS-Key-Policy",
+ {
+ document: new PolicyDocument({
+ statements: [
+ new PolicyStatement({
+ effect: Effect.ALLOW,
+ actions: [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ "kms:GenerateDataKeyPair"
+ ],
+ 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}`,
+ ]
+ })
+ ]
+ }),
+ }
+ )
+
+ // IAM role
+ const GithubActionsPrincipal = new FederatedPrincipal(
+ "arn:aws:iam::" + this.account + ":oidc-provider/token.actions.githubusercontent.com",
+ {
+ "StringEquals": {
+ "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
+ },
+ "StringLike": {
+ "token.actions.githubusercontent.com:sub": [
+ "repo:aws/amazon-s3-encryption-client-python:*",
+ "repo:aws/private-amazon-s3-encryption-client-dotnet-staging:*" // For S3EC-NET repo
+ ]
+ }
+ },
+ "sts:AssumeRoleWithWebIdentity"
+ )
+
+ // ToolsDevelopment role principal
+ const ToolsDevelopmentPrincipal = new ArnPrincipal("arn:aws:iam::" + this.account + ":role/ToolsDevelopment")
+
+ // Composite principal to allow both GitHub Actions and ToolsDevelopment to assume the role
+ const CompositePrincipalForRole = new CompositePrincipal(
+ GithubActionsPrincipal,
+ ToolsDevelopmentPrincipal
+ )
+
+ const S3ECGithubTestRole = new Role(
+ this,
+ "s3-github-test-role",
+ {
+ assumedBy: CompositePrincipalForRole,
+ roleName: "S3EC-Python-Github-test-role",
+ description: " Grant GitHub S3 put and get and KMS encrypt, decrypt, and generate access for testing",
+ path: "/",
+ managedPolicies: [S3ECGithubS3BucketPolicy, S3ECGithubKMSKeyPolicy]
+ }
+ );
+ }
+}
diff --git a/cdk/package-lock.json b/cdk/package-lock.json
new file mode 100644
index 00000000..fa174491
--- /dev/null
+++ b/cdk/package-lock.json
@@ -0,0 +1,4462 @@
+{
+ "name": "cdk",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "cdk",
+ "version": "0.1.0",
+ "dependencies": {
+ "aws-cdk-lib": "^2.240.0",
+ "constructs": "^10.0.0",
+ "source-map-support": "^0.5.21"
+ },
+ "bin": {
+ "cdk": "bin/cdk.js"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.3",
+ "@types/node": "20.4.10",
+ "aws-cdk": "2.92.0",
+ "jest": "^29.6.2",
+ "ts-jest": "^29.1.1",
+ "ts-node": "^10.9.1",
+ "typescript": "~5.1.6"
+ }
+ },
+ "node_modules/@ampproject/remapping": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@aws-cdk/asset-awscli-v1": {
+ "version": "2.2.263",
+ "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.263.tgz",
+ "integrity": "sha512-X9JvcJhYcb7PHs8R7m4zMablO5C9PGb/hYfLnxds9h/rKJu6l7MiXE/SabCibuehxPnuO/vk+sVVJiUWrccarQ=="
+ },
+ "node_modules/@aws-cdk/asset-node-proxy-agent-v6": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.0.tgz",
+ "integrity": "sha512-7bY3J8GCVxLupn/kNmpPc5VJz8grx+4RKfnnJiO1LG+uxkZfANZG3RMHhE+qQxxwkyQ9/MfPtTpf748UhR425A==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@aws-cdk/cloud-assembly-schema": {
+ "version": "50.4.0",
+ "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-50.4.0.tgz",
+ "integrity": "sha512-9Cplwc5C+SNe3hMfqZET7gXeM68tiH2ytQytCi+zz31Bn7O3GAgAnC2dYe+HWnZAgVH788ZkkBwnYXkeqx7v4g==",
+ "bundleDependencies": [
+ "jsonschema",
+ "semver"
+ ],
+ "dependencies": {
+ "jsonschema": "~1.4.1",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ }
+ },
+ "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": {
+ "version": "1.4.1",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": {
+ "version": "7.7.3",
+ "inBundle": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
+ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz",
+ "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz",
+ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@ampproject/remapping": "^2.2.0",
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.0",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.27.3",
+ "@babel/helpers": "^7.27.6",
+ "@babel/parser": "^7.28.0",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.0",
+ "@babel/types": "^7.28.0",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz",
+ "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.28.0",
+ "@babel/types": "^7.28.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
+ "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.27.2",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
+ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.27.1",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.27.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz",
+ "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1",
+ "@babel/traverse": "^7.27.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz",
+ "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
+ "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.28.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz",
+ "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz",
+ "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-async-generators": {
+ "version": "7.8.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+ "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-bigint": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz",
+ "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-class-properties": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+ "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.12.13"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-class-static-block": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz",
+ "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-attributes": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz",
+ "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-meta": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+ "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-json-strings": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+ "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz",
+ "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+ "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+ "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-numeric-separator": {
+ "version": "7.10.4",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+ "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.10.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-object-rest-spread": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+ "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-optional-catch-binding": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+ "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-optional-chaining": {
+ "version": "7.8.3",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+ "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.8.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-private-property-in-object": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz",
+ "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-top-level-await": {
+ "version": "7.14.5",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+ "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.14.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-typescript": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz",
+ "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.27.2",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
+ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/parser": "^7.27.2",
+ "@babel/types": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz",
+ "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.27.1",
+ "@babel/generator": "^7.28.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.0",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.28.2",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz",
+ "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bcoe/v8-coverage": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@cspotcode/source-map-support": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
+ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.9"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@istanbuljs/load-nyc-config": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+ "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.3.1",
+ "find-up": "^4.1.0",
+ "get-package-type": "^0.1.0",
+ "js-yaml": "^3.13.1",
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@istanbuljs/schema": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@jest/console": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz",
+ "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/core": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz",
+ "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/console": "^29.7.0",
+ "@jest/reporters": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "ansi-escapes": "^4.2.1",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "exit": "^0.1.2",
+ "graceful-fs": "^4.2.9",
+ "jest-changed-files": "^29.7.0",
+ "jest-config": "^29.7.0",
+ "jest-haste-map": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-resolve": "^29.7.0",
+ "jest-resolve-dependencies": "^29.7.0",
+ "jest-runner": "^29.7.0",
+ "jest-runtime": "^29.7.0",
+ "jest-snapshot": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "jest-watcher": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jest/environment": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
+ "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/fake-timers": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "jest-mock": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/expect": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz",
+ "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "expect": "^29.7.0",
+ "jest-snapshot": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/expect-utils": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz",
+ "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jest-get-type": "^29.6.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/fake-timers": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz",
+ "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@sinonjs/fake-timers": "^10.0.2",
+ "@types/node": "*",
+ "jest-message-util": "^29.7.0",
+ "jest-mock": "^29.7.0",
+ "jest-util": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/globals": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
+ "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/environment": "^29.7.0",
+ "@jest/expect": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "jest-mock": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/reporters": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
+ "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@bcoe/v8-coverage": "^0.2.3",
+ "@jest/console": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "collect-v8-coverage": "^1.0.0",
+ "exit": "^0.1.2",
+ "glob": "^7.1.3",
+ "graceful-fs": "^4.2.9",
+ "istanbul-lib-coverage": "^3.0.0",
+ "istanbul-lib-instrument": "^6.0.0",
+ "istanbul-lib-report": "^3.0.0",
+ "istanbul-lib-source-maps": "^4.0.0",
+ "istanbul-reports": "^3.1.3",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-worker": "^29.7.0",
+ "slash": "^3.0.0",
+ "string-length": "^4.0.1",
+ "strip-ansi": "^6.0.0",
+ "v8-to-istanbul": "^9.0.1"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/source-map": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz",
+ "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "callsites": "^3.0.0",
+ "graceful-fs": "^4.2.9"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/test-result": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz",
+ "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/console": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "collect-v8-coverage": "^1.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/test-sequencer": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz",
+ "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/test-result": "^29.7.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/transform": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz",
+ "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.11.6",
+ "@jest/types": "^29.6.3",
+ "@jridgewell/trace-mapping": "^0.3.18",
+ "babel-plugin-istanbul": "^6.1.1",
+ "chalk": "^4.0.0",
+ "convert-source-map": "^2.0.0",
+ "fast-json-stable-stringify": "^2.1.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-util": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "pirates": "^4.0.4",
+ "slash": "^3.0.0",
+ "write-file-atomic": "^4.0.2"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jest/types": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz",
+ "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "@types/istanbul-lib-coverage": "^2.0.0",
+ "@types/istanbul-reports": "^3.0.0",
+ "@types/node": "*",
+ "@types/yargs": "^17.0.8",
+ "chalk": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.12",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
+ "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
+ "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.29",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
+ "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.8",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
+ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sinonjs/commons": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
+ "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "type-detect": "4.0.8"
+ }
+ },
+ "node_modules/@sinonjs/fake-timers": {
+ "version": "10.3.0",
+ "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz",
+ "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@sinonjs/commons": "^3.0.0"
+ }
+ },
+ "node_modules/@tsconfig/node10": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
+ "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node12": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+ "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node14": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+ "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node16": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/graceful-fs": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
+ "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/istanbul-lib-coverage": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
+ "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/istanbul-lib-report": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz",
+ "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/istanbul-lib-coverage": "*"
+ }
+ },
+ "node_modules/@types/istanbul-reports": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz",
+ "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/istanbul-lib-report": "*"
+ }
+ },
+ "node_modules/@types/jest": {
+ "version": "29.5.14",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
+ "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "expect": "^29.0.0",
+ "pretty-format": "^29.0.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.4.10",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.10.tgz",
+ "integrity": "sha512-vwzFiiy8Rn6E0MtA13/Cxxgpan/N6UeNYR9oUu6kuJWxu6zCk98trcDp8CBhbtaeuq9SykCmXkFr2lWLoPcvLg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/stack-utils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
+ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/yargs": {
+ "version": "17.0.33",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
+ "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/yargs-parser": "*"
+ }
+ },
+ "node_modules/@types/yargs-parser": {
+ "version": "21.0.3",
+ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
+ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/acorn": {
+ "version": "8.15.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
+ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.4",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
+ "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ansi-escapes": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+ "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-fest": "^0.21.3"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+ "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "sprintf-js": "~1.0.2"
+ }
+ },
+ "node_modules/aws-cdk": {
+ "version": "2.92.0",
+ "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.92.0.tgz",
+ "integrity": "sha512-9aAWJvZWSBJQxcsDopXYUAm6/pGz6vOQy2zfkn+YBuBkNelvW+ok15KPY4xn5m76tYnN79W03Gnfp/nxZUlcww==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "cdk": "bin/cdk"
+ },
+ "engines": {
+ "node": ">= 14.15.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/aws-cdk-lib": {
+ "version": "2.240.0",
+ "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.240.0.tgz",
+ "integrity": "sha512-3dXmUnPB5kK0VgrNHOlV3jiQM4Dungukk/CV91nclO2lgNcrGyigauJdzmz9sOmI1gbKJJ2SRAotaXityzZMRw==",
+ "bundleDependencies": [
+ "@balena/dockerignore",
+ "@aws-cdk/cloud-assembly-api",
+ "case",
+ "fs-extra",
+ "ignore",
+ "jsonschema",
+ "minimatch",
+ "punycode",
+ "semver",
+ "table",
+ "yaml",
+ "mime-types"
+ ],
+ "dependencies": {
+ "@aws-cdk/asset-awscli-v1": "2.2.263",
+ "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0",
+ "@aws-cdk/cloud-assembly-api": "^2.0.1",
+ "@aws-cdk/cloud-assembly-schema": "^50.3.0",
+ "@balena/dockerignore": "^1.0.2",
+ "case": "1.6.3",
+ "fs-extra": "^11.3.3",
+ "ignore": "^5.3.2",
+ "jsonschema": "^1.5.0",
+ "mime-types": "^2.1.35",
+ "minimatch": "^10.2.1",
+ "punycode": "^2.3.1",
+ "semver": "^7.7.4",
+ "table": "^6.9.0",
+ "yaml": "1.10.2"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "constructs": "^10.5.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": {
+ "version": "2.0.1",
+ "bundleDependencies": [
+ "jsonschema",
+ "semver"
+ ],
+ "inBundle": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jsonschema": "~1.4.1",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "@aws-cdk/cloud-assembly-schema": ">=50.3.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": {
+ "version": "1.4.1",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": {
+ "version": "7.7.3",
+ "inBundle": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": {
+ "version": "1.0.2",
+ "inBundle": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/aws-cdk-lib/node_modules/ajv": {
+ "version": "8.18.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/astral-regex": {
+ "version": "2.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/brace-expansion": {
+ "version": "5.0.3",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/case": {
+ "version": "1.6.3",
+ "inBundle": true,
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/color-convert": {
+ "version": "2.0.1",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/color-name": {
+ "version": "1.1.4",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/fast-uri": {
+ "version": "3.1.0",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "inBundle": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/aws-cdk-lib/node_modules/fs-extra": {
+ "version": "11.3.3",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=14.14"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "inBundle": true,
+ "license": "ISC"
+ },
+ "node_modules/aws-cdk-lib/node_modules/ignore": {
+ "version": "5.3.2",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/jsonfile": {
+ "version": "6.2.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/jsonschema": {
+ "version": "1.5.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/lodash.truncate": {
+ "version": "4.4.2",
+ "inBundle": true,
+ "license": "MIT"
+ },
+ "node_modules/aws-cdk-lib/node_modules/mime-db": {
+ "version": "1.52.0",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/mime-types": {
+ "version": "2.1.35",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/minimatch": {
+ "version": "10.2.2",
+ "inBundle": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/punycode": {
+ "version": "2.3.1",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/require-from-string": {
+ "version": "2.0.2",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/semver": {
+ "version": "7.7.4",
+ "inBundle": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/slice-ansi": {
+ "version": "4.0.0",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "astral-regex": "^2.0.0",
+ "is-fullwidth-code-point": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/string-width": {
+ "version": "4.2.3",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "inBundle": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/table": {
+ "version": "6.9.0",
+ "inBundle": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "ajv": "^8.0.1",
+ "lodash.truncate": "^4.4.2",
+ "slice-ansi": "^4.0.0",
+ "string-width": "^4.2.3",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/universalify": {
+ "version": "2.0.1",
+ "inBundle": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/aws-cdk-lib/node_modules/yaml": {
+ "version": "1.10.2",
+ "inBundle": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/babel-jest": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
+ "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/transform": "^29.7.0",
+ "@types/babel__core": "^7.1.14",
+ "babel-plugin-istanbul": "^6.1.1",
+ "babel-preset-jest": "^29.6.3",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.9",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.8.0"
+ }
+ },
+ "node_modules/babel-plugin-istanbul": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
+ "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@istanbuljs/load-nyc-config": "^1.0.0",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-instrument": "^5.0.4",
+ "test-exclude": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
+ "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/core": "^7.12.3",
+ "@babel/parser": "^7.14.7",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/babel-plugin-jest-hoist": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz",
+ "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.3.3",
+ "@babel/types": "^7.3.3",
+ "@types/babel__core": "^7.1.14",
+ "@types/babel__traverse": "^7.0.6"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/babel-preset-current-node-syntax": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz",
+ "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-syntax-async-generators": "^7.8.4",
+ "@babel/plugin-syntax-bigint": "^7.8.3",
+ "@babel/plugin-syntax-class-properties": "^7.12.13",
+ "@babel/plugin-syntax-class-static-block": "^7.14.5",
+ "@babel/plugin-syntax-import-attributes": "^7.24.7",
+ "@babel/plugin-syntax-import-meta": "^7.10.4",
+ "@babel/plugin-syntax-json-strings": "^7.8.3",
+ "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4",
+ "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+ "@babel/plugin-syntax-numeric-separator": "^7.10.4",
+ "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+ "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+ "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+ "@babel/plugin-syntax-private-property-in-object": "^7.14.5",
+ "@babel/plugin-syntax-top-level-await": "^7.14.5"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0 || ^8.0.0-0"
+ }
+ },
+ "node_modules/babel-preset-jest": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz",
+ "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "babel-plugin-jest-hoist": "^29.6.3",
+ "babel-preset-current-node-syntax": "^1.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.25.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
+ "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "caniuse-lite": "^1.0.30001726",
+ "electron-to-chromium": "^1.5.173",
+ "node-releases": "^2.0.19",
+ "update-browserslist-db": "^1.1.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/bs-logger": {
+ "version": "0.2.6",
+ "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz",
+ "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-json-stable-stringify": "2.x"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/bser": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
+ "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "node-int64": "^0.4.0"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "license": "MIT"
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001731",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz",
+ "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/char-regex": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
+ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ci-info": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
+ "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/sibiraj-s"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cjs-module-lexer": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz",
+ "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/co": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
+ "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">= 1.0.0",
+ "node": ">= 0.12.0"
+ }
+ },
+ "node_modules/collect-v8-coverage": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
+ "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/constructs": {
+ "version": "10.5.1",
+ "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.5.1.tgz",
+ "integrity": "sha512-f/TfFXiS3G/yVIXDjOQn9oTlyu9Wo7Fxyjj7lb8r92iO81jR2uST+9MstxZTmDGx/CgIbxCXkFXgupnLTNxQZg=="
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/create-jest": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
+ "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "exit": "^0.1.2",
+ "graceful-fs": "^4.2.9",
+ "jest-config": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "prompts": "^2.0.1"
+ },
+ "bin": {
+ "create-jest": "bin/create-jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/create-require": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/dedent": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz",
+ "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "babel-plugin-macros": "^3.1.0"
+ },
+ "peerDependenciesMeta": {
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-newline": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
+ "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/diff": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
+ "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
+ "dev": true,
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.197",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.197.tgz",
+ "integrity": "sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emittery": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz",
+ "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/emittery?sponsor=1"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/error-ex": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+ "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.2.1"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/esprima": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "bin": {
+ "esparse": "bin/esparse.js",
+ "esvalidate": "bin/esvalidate.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/execa": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+ "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^6.0.0",
+ "human-signals": "^2.1.0",
+ "is-stream": "^2.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^4.0.1",
+ "onetime": "^5.1.2",
+ "signal-exit": "^3.0.3",
+ "strip-final-newline": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/exit": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
+ "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==",
+ "dev": true,
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/expect": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz",
+ "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/expect-utils": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fb-watchman": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz",
+ "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "bser": "2.1.1"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fs.realpath": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/get-package-type": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+ "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/glob": {
+ "version": "7.2.3",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+ "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+ "deprecated": "Glob versions prior to v9 are no longer supported",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "fs.realpath": "^1.0.0",
+ "inflight": "^1.0.4",
+ "inherits": "2",
+ "minimatch": "^3.1.1",
+ "once": "^1.3.0",
+ "path-is-absolute": "^1.0.0"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/handlebars": {
+ "version": "4.7.8",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
+ "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minimist": "^1.2.5",
+ "neo-async": "^2.6.2",
+ "source-map": "^0.6.1",
+ "wordwrap": "^1.0.0"
+ },
+ "bin": {
+ "handlebars": "bin/handlebars"
+ },
+ "engines": {
+ "node": ">=0.4.7"
+ },
+ "optionalDependencies": {
+ "uglify-js": "^3.1.4"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/human-signals": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+ "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=10.17.0"
+ }
+ },
+ "node_modules/import-local": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz",
+ "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pkg-dir": "^4.2.0",
+ "resolve-cwd": "^3.0.0"
+ },
+ "bin": {
+ "import-local-fixture": "fixtures/cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/inflight": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+ "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+ "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "once": "^1.3.0",
+ "wrappy": "1"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-arrayish": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-generator-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
+ "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/istanbul-lib-coverage": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
+ "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/istanbul-lib-instrument": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz",
+ "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/core": "^7.23.9",
+ "@babel/parser": "^7.23.9",
+ "@istanbuljs/schema": "^0.1.3",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-instrument/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+ "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0",
+ "source-map": "^0.6.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
+ "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/jest": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
+ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/core": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "import-local": "^3.0.2",
+ "jest-cli": "^29.7.0"
+ },
+ "bin": {
+ "jest": "bin/jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jest-changed-files": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz",
+ "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "execa": "^5.0.0",
+ "jest-util": "^29.7.0",
+ "p-limit": "^3.1.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-circus": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz",
+ "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/environment": "^29.7.0",
+ "@jest/expect": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "co": "^4.6.0",
+ "dedent": "^1.0.0",
+ "is-generator-fn": "^2.0.0",
+ "jest-each": "^29.7.0",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-runtime": "^29.7.0",
+ "jest-snapshot": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "p-limit": "^3.1.0",
+ "pretty-format": "^29.7.0",
+ "pure-rand": "^6.0.0",
+ "slash": "^3.0.0",
+ "stack-utils": "^2.0.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-cli": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz",
+ "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/core": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "create-jest": "^29.7.0",
+ "exit": "^0.1.2",
+ "import-local": "^3.0.2",
+ "jest-config": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "yargs": "^17.3.1"
+ },
+ "bin": {
+ "jest": "bin/jest.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "node-notifier": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jest-config": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz",
+ "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.11.6",
+ "@jest/test-sequencer": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "babel-jest": "^29.7.0",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "deepmerge": "^4.2.2",
+ "glob": "^7.1.3",
+ "graceful-fs": "^4.2.9",
+ "jest-circus": "^29.7.0",
+ "jest-environment-node": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-regex-util": "^29.6.3",
+ "jest-resolve": "^29.7.0",
+ "jest-runner": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "parse-json": "^5.2.0",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "peerDependencies": {
+ "@types/node": "*",
+ "ts-node": ">=9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "ts-node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jest-diff": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz",
+ "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.0.0",
+ "diff-sequences": "^29.6.3",
+ "jest-get-type": "^29.6.3",
+ "pretty-format": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-docblock": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz",
+ "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-newline": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-each": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz",
+ "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "chalk": "^4.0.0",
+ "jest-get-type": "^29.6.3",
+ "jest-util": "^29.7.0",
+ "pretty-format": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-environment-node": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
+ "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/environment": "^29.7.0",
+ "@jest/fake-timers": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "jest-mock": "^29.7.0",
+ "jest-util": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-get-type": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz",
+ "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-haste-map": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz",
+ "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@types/graceful-fs": "^4.1.3",
+ "@types/node": "*",
+ "anymatch": "^3.0.3",
+ "fb-watchman": "^2.0.0",
+ "graceful-fs": "^4.2.9",
+ "jest-regex-util": "^29.6.3",
+ "jest-util": "^29.7.0",
+ "jest-worker": "^29.7.0",
+ "micromatch": "^4.0.4",
+ "walker": "^1.0.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "^2.3.2"
+ }
+ },
+ "node_modules/jest-leak-detector": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz",
+ "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jest-get-type": "^29.6.3",
+ "pretty-format": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-matcher-utils": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz",
+ "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.0.0",
+ "jest-diff": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "pretty-format": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-message-util": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz",
+ "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.12.13",
+ "@jest/types": "^29.6.3",
+ "@types/stack-utils": "^2.0.0",
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.9",
+ "micromatch": "^4.0.4",
+ "pretty-format": "^29.7.0",
+ "slash": "^3.0.0",
+ "stack-utils": "^2.0.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-mock": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz",
+ "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "jest-util": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-pnp-resolver": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz",
+ "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "peerDependencies": {
+ "jest-resolve": "*"
+ },
+ "peerDependenciesMeta": {
+ "jest-resolve": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jest-regex-util": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz",
+ "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-resolve": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz",
+ "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "^4.0.0",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "jest-pnp-resolver": "^1.2.2",
+ "jest-util": "^29.7.0",
+ "jest-validate": "^29.7.0",
+ "resolve": "^1.20.0",
+ "resolve.exports": "^2.0.0",
+ "slash": "^3.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-resolve-dependencies": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz",
+ "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jest-regex-util": "^29.6.3",
+ "jest-snapshot": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-runner": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz",
+ "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/console": "^29.7.0",
+ "@jest/environment": "^29.7.0",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "emittery": "^0.13.1",
+ "graceful-fs": "^4.2.9",
+ "jest-docblock": "^29.7.0",
+ "jest-environment-node": "^29.7.0",
+ "jest-haste-map": "^29.7.0",
+ "jest-leak-detector": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-resolve": "^29.7.0",
+ "jest-runtime": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "jest-watcher": "^29.7.0",
+ "jest-worker": "^29.7.0",
+ "p-limit": "^3.1.0",
+ "source-map-support": "0.5.13"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-runner/node_modules/source-map-support": {
+ "version": "0.5.13",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
+ "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/jest-runtime": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz",
+ "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/environment": "^29.7.0",
+ "@jest/fake-timers": "^29.7.0",
+ "@jest/globals": "^29.7.0",
+ "@jest/source-map": "^29.6.3",
+ "@jest/test-result": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "cjs-module-lexer": "^1.0.0",
+ "collect-v8-coverage": "^1.0.0",
+ "glob": "^7.1.3",
+ "graceful-fs": "^4.2.9",
+ "jest-haste-map": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-mock": "^29.7.0",
+ "jest-regex-util": "^29.6.3",
+ "jest-resolve": "^29.7.0",
+ "jest-snapshot": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "slash": "^3.0.0",
+ "strip-bom": "^4.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-snapshot": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz",
+ "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.11.6",
+ "@babel/generator": "^7.7.2",
+ "@babel/plugin-syntax-jsx": "^7.7.2",
+ "@babel/plugin-syntax-typescript": "^7.7.2",
+ "@babel/types": "^7.3.3",
+ "@jest/expect-utils": "^29.7.0",
+ "@jest/transform": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "babel-preset-current-node-syntax": "^1.0.0",
+ "chalk": "^4.0.0",
+ "expect": "^29.7.0",
+ "graceful-fs": "^4.2.9",
+ "jest-diff": "^29.7.0",
+ "jest-get-type": "^29.6.3",
+ "jest-matcher-utils": "^29.7.0",
+ "jest-message-util": "^29.7.0",
+ "jest-util": "^29.7.0",
+ "natural-compare": "^1.4.0",
+ "pretty-format": "^29.7.0",
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-snapshot/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/jest-util": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz",
+ "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "chalk": "^4.0.0",
+ "ci-info": "^3.2.0",
+ "graceful-fs": "^4.2.9",
+ "picomatch": "^2.2.3"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-validate": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz",
+ "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/types": "^29.6.3",
+ "camelcase": "^6.2.0",
+ "chalk": "^4.0.0",
+ "jest-get-type": "^29.6.3",
+ "leven": "^3.1.0",
+ "pretty-format": "^29.7.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-validate/node_modules/camelcase": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
+ "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/jest-watcher": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz",
+ "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/test-result": "^29.7.0",
+ "@jest/types": "^29.6.3",
+ "@types/node": "*",
+ "ansi-escapes": "^4.2.1",
+ "chalk": "^4.0.0",
+ "emittery": "^0.13.1",
+ "jest-util": "^29.7.0",
+ "string-length": "^4.0.1"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-worker": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz",
+ "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "jest-util": "^29.7.0",
+ "merge-stream": "^2.0.0",
+ "supports-color": "^8.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/jest-worker/node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "dev": true,
+ "dependencies": {
+ "argparse": "^1.0.7",
+ "esprima": "^4.0.0"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-parse-even-better-errors": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/kleur": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/leven": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/lodash.memoize": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/make-dir/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/make-error": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/makeerror": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
+ "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tmpl": "1.0.5"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz",
+ "integrity": "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==",
+ "dev": true,
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/neo-async": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-int64": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.19",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
+ "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+ "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/onetime": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-locate/node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-is-absolute": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+ "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pkg-dir": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+ "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "find-up": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/prompts": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+ "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kleur": "^3.0.3",
+ "sisteransi": "^1.0.5"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/pure-rand": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
+ "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/dubzzz"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fast-check"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.10",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
+ "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.0",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-cwd": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
+ "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "resolve-from": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+ "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/resolve.exports": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz",
+ "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/sisteransi": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/slash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+ "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/sprintf-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/stack-utils": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
+ "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "escape-string-regexp": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/string-length": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
+ "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "char-regex": "^1.0.2",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-bom": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+ "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+ "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/test-exclude": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+ "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@istanbuljs/schema": "^0.1.2",
+ "glob": "^7.1.4",
+ "minimatch": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tmpl": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
+ "dev": true,
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-jest": {
+ "version": "29.4.1",
+ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.1.tgz",
+ "integrity": "sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "bs-logger": "^0.2.6",
+ "fast-json-stable-stringify": "^2.1.0",
+ "handlebars": "^4.7.8",
+ "json5": "^2.2.3",
+ "lodash.memoize": "^4.1.2",
+ "make-error": "^1.3.6",
+ "semver": "^7.7.2",
+ "type-fest": "^4.41.0",
+ "yargs-parser": "^21.1.1"
+ },
+ "bin": {
+ "ts-jest": "cli.js"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": ">=7.0.0-beta.0 <8",
+ "@jest/transform": "^29.0.0 || ^30.0.0",
+ "@jest/types": "^29.0.0 || ^30.0.0",
+ "babel-jest": "^29.0.0 || ^30.0.0",
+ "jest": "^29.0.0 || ^30.0.0",
+ "jest-util": "^29.0.0 || ^30.0.0",
+ "typescript": ">=4.3 <6"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "@jest/transform": {
+ "optional": true
+ },
+ "@jest/types": {
+ "optional": true
+ },
+ "babel-jest": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jest-util": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/ts-jest/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/ts-jest/node_modules/type-fest": {
+ "version": "4.41.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
+ "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/ts-node": {
+ "version": "10.9.2",
+ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "^0.8.0",
+ "@tsconfig/node10": "^1.0.7",
+ "@tsconfig/node12": "^1.0.7",
+ "@tsconfig/node14": "^1.0.0",
+ "@tsconfig/node16": "^1.0.2",
+ "acorn": "^8.4.1",
+ "acorn-walk": "^8.1.1",
+ "arg": "^4.1.0",
+ "create-require": "^1.1.0",
+ "diff": "^4.0.1",
+ "make-error": "^1.1.1",
+ "v8-compile-cache-lib": "^3.0.1",
+ "yn": "3.1.1"
+ },
+ "bin": {
+ "ts-node": "dist/bin.js",
+ "ts-node-cwd": "dist/bin-cwd.js",
+ "ts-node-esm": "dist/bin-esm.js",
+ "ts-node-script": "dist/bin-script.js",
+ "ts-node-transpile-only": "dist/bin-transpile.js",
+ "ts-script": "dist/bin-script-deprecated.js"
+ },
+ "peerDependencies": {
+ "@swc/core": ">=1.2.50",
+ "@swc/wasm": ">=1.2.50",
+ "@types/node": "*",
+ "typescript": ">=2.7"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ },
+ "@swc/wasm": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/type-fest": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz",
+ "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/uglify-js": {
+ "version": "3.19.3",
+ "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
+ "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "optional": true,
+ "bin": {
+ "uglifyjs": "bin/uglifyjs"
+ },
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+ "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/v8-compile-cache-lib": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/v8-to-istanbul": {
+ "version": "9.3.0",
+ "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
+ "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.12",
+ "@types/istanbul-lib-coverage": "^2.0.1",
+ "convert-source-map": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10.12.0"
+ }
+ },
+ "node_modules/walker": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+ "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "makeerror": "1.0.12"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/wordwrap": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/write-file-atomic": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz",
+ "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "imurmurhash": "^0.1.4",
+ "signal-exit": "^3.0.7"
+ },
+ "engines": {
+ "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yn": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/cdk/package.json b/cdk/package.json
new file mode 100644
index 00000000..7cc118ae
--- /dev/null
+++ b/cdk/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "cdk",
+ "version": "0.1.0",
+ "bin": {
+ "cdk": "bin/cdk.js"
+ },
+ "scripts": {
+ "build": "tsc",
+ "watch": "tsc -w",
+ "test": "jest",
+ "cdk": "cdk"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.3",
+ "@types/node": "20.4.10",
+ "aws-cdk": "2.92.0",
+ "jest": "^29.6.2",
+ "ts-jest": "^29.1.1",
+ "ts-node": "^10.9.1",
+ "typescript": "~5.1.6"
+ },
+ "dependencies": {
+ "aws-cdk-lib": "^2.240.0",
+ "constructs": "^10.0.0",
+ "source-map-support": "^0.5.21"
+ }
+}
diff --git a/cdk/test/cdk.test.ts b/cdk/test/cdk.test.ts
new file mode 100644
index 00000000..1e6b29c8
--- /dev/null
+++ b/cdk/test/cdk.test.ts
@@ -0,0 +1,17 @@
+// import * as cdk from 'aws-cdk-lib';
+// import { Template } from 'aws-cdk-lib/assertions';
+// import * as Cdk from '../lib/cdk-stack';
+
+// example test. To run these tests, uncomment this file along with the
+// example resource in lib/cdk-stack.ts
+test('SQS Queue Created', () => {
+// const app = new cdk.App();
+// // WHEN
+// const stack = new Cdk.CdkStack(app, 'MyTestStack');
+// // THEN
+// const template = Template.fromStack(stack);
+
+// template.hasResourceProperties('AWS::SQS::Queue', {
+// VisibilityTimeout: 300
+// });
+});
diff --git a/cdk/tsconfig.json b/cdk/tsconfig.json
new file mode 100644
index 00000000..aaa7dc51
--- /dev/null
+++ b/cdk/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": [
+ "es2020",
+ "dom"
+ ],
+ "declaration": true,
+ "strict": true,
+ "noImplicitAny": true,
+ "strictNullChecks": true,
+ "noImplicitThis": true,
+ "alwaysStrict": true,
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": false,
+ "inlineSourceMap": true,
+ "inlineSources": true,
+ "experimentalDecorators": true,
+ "strictPropertyInitialization": false,
+ "typeRoots": [
+ "./node_modules/@types"
+ ]
+ },
+ "exclude": [
+ "node_modules",
+ "cdk.out"
+ ]
+}
diff --git a/compliance_exceptions/decryption_exceptions.md b/compliance_exceptions/decryption_exceptions.md
new file mode 100644
index 00000000..4919f2ce
--- /dev/null
+++ b/compliance_exceptions/decryption_exceptions.md
@@ -0,0 +1,49 @@
+# Compliance Exceptions for Decryption Implementation
+
+## Summary
+
+The Python S3 Encryption Client does not currently support Ranged Gets.
+Ranged Gets allow downloading and decrypting a subset of bytes from an encrypted S3 object.
+This is an optional feature per the specification ("MAY support") and is planned for a future release.
+
+## Ranged Gets
+
+##= specification/s3-encryption/decryption.md#ranged-gets
+##= type=exception
+##% The S3EC MAY support the "range" parameter on GetObject which specifies a subset of bytes to download and decrypt.
+
+Justification: Ranged Gets are not yet implemented in the Python S3 Encryption Client. The specification uses MAY, making this an optional feature. This is planned for a future release.
+
+---
+
+##= specification/s3-encryption/decryption.md#ranged-gets
+##= type=exception
+##% If the S3EC supports Ranged Gets, the S3EC MUST adjust the customer-provided range to include the beginning and end of the cipher blocks for the given range.
+
+Justification: Not applicable since Ranged Gets are not yet supported. When Ranged Gets are implemented, this requirement will be fulfilled.
+
+---
+
+##= specification/s3-encryption/decryption.md#ranged-gets
+##= type=exception
+##% If the object was encrypted with ALG_AES_256_GCM_IV12_TAG16_NO_KDF, then ALG_AES_256_CTR_IV16_TAG16_NO_KDF MUST be used to decrypt the range of the object.
+
+Justification: Not applicable since Ranged Gets are not yet supported. When Ranged Gets are implemented, the correct CTR-mode algorithm suite will be used for GCM-encrypted objects.
+
+---
+
+##= specification/s3-encryption/decryption.md#ranged-gets
+##= type=exception
+##% If the object was encrypted with ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, then ALG_AES_256_CTR_HKDF_SHA512_COMMIT_KEY MUST be used to decrypt the range of the object.
+
+Justification: Not applicable since Ranged Gets are not yet supported. When Ranged Gets are implemented, the correct CTR-mode algorithm suite will be used for key-committing objects.
+
+---
+
+##= specification/s3-encryption/decryption.md#ranged-gets
+##= type=exception
+##% If the GetObject response contains a range, but the GetObject request does not contain a range, the S3EC MUST throw an exception.
+
+Justification: Not applicable since Ranged Gets are not yet supported. When Ranged Gets are implemented, this validation will be added to detect unexpected range responses.
+
+---
diff --git a/compliance_exceptions/encryption_exceptions.md b/compliance_exceptions/encryption_exceptions.md
new file mode 100644
index 00000000..bf7d9f62
--- /dev/null
+++ b/compliance_exceptions/encryption_exceptions.md
@@ -0,0 +1,63 @@
+# Compliance Exceptions for Encryption Implementation
+
+## Summary
+
+The Python S3 Encryption Client does not implement AES-CTR algorithm suites (used only for ranged-get decryption),
+does not yet validate IV/Message ID for zero values, does not validate maximum plaintext length,
+and relies on Python's `cryptography` library to automatically append GCM auth tags.
+
+## AES-CTR Algorithm Suites
+
+##= specification/s3-encryption/encryption.md#alg-aes-256-ctr-hkdf-sha512-commit-key
+##= type=exception
+##% Attempts to encrypt using key committing AES-CTR MUST fail.
+
+Justification: The AES-CTR algorithm suites are only used for ranged-get decryption. Since ranged gets are not yet implemented, these algorithm suites are not defined in the `AlgorithmSuite` enum and cannot be selected for encryption. The constraint is satisfied structurally.
+
+---
+
+##= specification/s3-encryption/encryption.md#alg-aes-256-ctr-iv16-tag16-no-kdf
+##= type=exception
+##% Attempts to encrypt using AES-CTR MUST fail.
+
+Justification: Same as above. AES-CTR is not available as an algorithm suite option, so it cannot be used for encryption.
+
+---
+
+## GCM Auth Tag Appending
+
+##= specification/s3-encryption/encryption.md#alg-aes-256-gcm-hkdf-sha512-commit-key
+##= type=exception
+##% The client MUST append the GCM auth tag to the ciphertext if the underlying crypto provider does not do so automatically.
+
+Justification: Python's `cryptography` library (`AESGCM.encrypt`) automatically appends the GCM authentication tag to the ciphertext. No manual appending is needed.
+
+---
+
+##= specification/s3-encryption/encryption.md#alg-aes-256-gcm-iv12-tag16-no-kdf
+##= type=exception
+##% The client MUST append the GCM auth tag to the ciphertext if the underlying crypto provider does not do so automatically.
+
+Justification: Python's `cryptography` library (`AESGCM.encrypt`) automatically appends the GCM authentication tag to the ciphertext. No manual appending is needed.
+
+---
+
+## Cipher Initialization Validation
+
+##= specification/s3-encryption/encryption.md#cipher-initialization
+##= type=exception
+##% The client SHOULD validate that the generated IV or Message ID is not zeros.
+
+Justification: This SHOULD-level validation is not yet implemented. The IV and Message ID are generated using `os.urandom()`, which is cryptographically secure and extremely unlikely to produce all-zero output. This validation is planned for a future release.
+
+---
+
+## Plaintext Length Validation
+
+##= specification/s3-encryption/encryption.md#content-encryption
+##= type=exception
+##% The client MUST validate that the length of the plaintext bytes does not exceed the algorithm suite's cipher's maximum content length in bytes.
+
+Justification: Maximum plaintext length validation is not yet implemented. For AES-GCM with a 12-byte IV, the maximum plaintext size is approximately 64 GiB, which exceeds practical S3 single-object upload limits. This validation is planned for a future release.
+
+---
diff --git a/compliance_exceptions/kms_keyring_exceptions.md b/compliance_exceptions/kms_keyring_exceptions.md
new file mode 100644
index 00000000..0da55fb3
--- /dev/null
+++ b/compliance_exceptions/kms_keyring_exceptions.md
@@ -0,0 +1,214 @@
+# Compliance Exceptions for KMS Keyring Implementation
+
+## Summary
+
+The Python S3 Encryption Client implementation takes a pragmatic approach that:
+1. Simplifies the keyring architecture by not implementing the full abstract method pattern (GenerateDataKey, EncryptDataKey, DecryptDataKey)
+2. Defers validation to the AWS SDK where appropriate (key identifier validation)
+3. Uses more efficient KMS API patterns (GenerateDataKey instead of separate Generate + Encrypt)
+4. Omits optional features like custom User Agent strings (planned for future enhancement)
+
+## TODOs
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+##= type=TODO
+##% - A custom API Name or User Agent string SHOULD be provided in order to provide metrics on KMS calls associated with the S3 Encryption Client.
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#kmsv1
+##= type=TODO
+##% - A custom API Name or User Agent string SHOULD be provided in order to provide metrics on KMS calls associated with the S3 Encryption Client.
+
+## Initialization Validation
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#initialization
+##= type=exception
+##% The KmsKeyring MAY validate that the AWS KMS key identifier is not null or empty.
+
+Justification: This validation is not implemented. The Python implementation relies on attrs field validation and KMS SDK to catch invalid key identifiers.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#initialization
+##= type=exception
+##% If the KmsKeyring validates that the AWS KMS key identifier is not null or empty, then it MUST throw an exception.
+
+Justification: Not applicable since the MAY validation above is not implemented. If we don't validate, we don't need to throw an exception for validation failure.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#initialization
+##= type=exception
+##% The KmsKeyring MAY validate that the AWS KMS key identifier is [a valid AWS KMS Key identifier](../../framework/aws-kms/aws-kms-key-arn.md#a-valid-aws-kms-identifier).
+
+Justification: This validation is not implemented. The Python implementation defers validation to the AWS KMS SDK, which will return an error if the key identifier is invalid.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#initialization
+##= type=exception
+##% If the KmsKeyring validates that the AWS KMS key identifier is not a valid AWS KMS Key identifier, then it MUST throw an exception.
+
+Justification: Not applicable since the MAY validation above is not implemented. If we don't validate, we don't need to throw an exception for validation failure.
+
+---
+
+## EncryptDataKey Method
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#encryptdatakey
+##= type=exception
+##% The KmsKeyring MUST implement the EncryptDataKey method.
+
+Justification: The Python implementation does not define a separate EncryptDataKey method. Instead, the encryption logic is directly implemented in the on_encrypt method using KMS GenerateDataKey API, which both generates and encrypts the data key in a single call. This is more efficient than the spec's pattern of separate Generate + Encrypt calls.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#encryptdatakey
+##= type=exception
+##% The keyring MUST call [AWS KMS Encrypt](https://docs.aws.amazon.com/kms/latest/APIReference/API_Encrypt.html) using the configured AWS KMS client.
+
+Justification: The Python implementation uses KMS GenerateDataKey instead of KMS Encrypt. GenerateDataKey is more efficient as it generates and encrypts the data key in a single API call, rather than requiring separate generation and encryption operations. This reduces latency and API call count.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#encryptdatakey
+##= type=exception
+##% - `KeyId` MUST be the configured AWS KMS key identifier.
+
+Justification: This requirement is for the KMS Encrypt API call. Since the Python implementation uses GenerateDataKey instead of Encrypt, this specific requirement doesn't apply. However, the KeyId parameter is correctly passed to GenerateDataKey.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#encryptdatakey
+##= type=exception
+##% - `PlaintextDataKey` MUST be the plaintext data key in the [encryption materials](../structures.md#encryption-materials).
+
+Justification: The Python implementation uses KMS GenerateDataKey instead of Encrypt. GenerateDataKey generates the plaintext key itself, so there is no pre-existing plaintext data key to pass in. The Plaintext parameter doesn't exist in the GenerateDataKey API - instead, the API returns both the plaintext and encrypted versions of the newly generated key.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#encryptdatakey
+##= type=exception
+##% - `EncryptionContext` MUST be the [encryption context](../structures.md#encryption-context) included in the input [encryption materials](../structures.md#encryption-materials).
+
+Justification: This requirement is for the KMS Encrypt API call. Since the Python implementation uses GenerateDataKey instead of Encrypt, this specific requirement doesn't apply. However, the EncryptionContext parameter is correctly passed to GenerateDataKey.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#encryptdatakey
+##= type=exception
+##% - A custom API Name or User Agent string SHOULD be provided in order to provide metrics on KMS calls associated with the S3 Encryption Client.
+
+Justification: Custom User Agent strings are not currently implemented. This is a future enhancement for better observability and metrics tracking. The functionality works correctly without it, but metrics attribution to the S3 Encryption Client would be improved with this addition.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#encryptdatakey
+##= type=exception
+##% If the call to [AWS KMS Encrypt](https://docs.aws.amazon.com/kms/latest/APIReference/API_Encrypt.html) does not succeed, OnEncrypt MUST fail.
+
+Justification: This requirement is for the KMS Encrypt API call. Since the Python implementation uses GenerateDataKey instead of Encrypt, this specific requirement doesn't apply. However, the implementation does correctly fail when GenerateDataKey fails.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#encryptdatakey
+##= type=exception
+##% If the call to AWS KMS Encrypt is successful, OnEncrypt MUST return the `CiphertextBlob` as a collection of bytes.
+
+Justification: This requirement is for the KMS Encrypt API call. Since the Python implementation uses GenerateDataKey instead of Encrypt, this specific requirement doesn't apply. However, the implementation does correctly return the CiphertextBlob from GenerateDataKey's response.
+
+---
+
+## DecryptDataKey Method Structure
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#kmsv1
+##= type=exception
+##% - A custom API Name or User Agent string SHOULD be provided in order to provide metrics on KMS calls associated with the S3 Encryption Client.
+
+Justification: Custom User Agent strings are not currently implemented for KMS Decrypt calls. This is a future enhancement for better observability and metrics tracking.
+
+---
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+##= type=exception
+##% - A custom API Name or User Agent string SHOULD be provided in order to provide metrics on KMS calls associated with the S3 Encryption Client.
+
+Justification: Custom User Agent strings are not currently implemented for KMS Decrypt calls in Kms+Context mode. This is a future enhancement for better observability and metrics tracking.
+
+---
+
+## S3 Keyring Abstract Methods
+
+##= specification/s3-encryption/materials/s3-keyring.md#abstract-methods
+##= type=exception
+##% - The S3 Keyring MUST define an abstract method GenerateDataKey.
+
+Justification: The S3Keyring base class does not define abstract methods for GenerateDataKey, EncryptDataKey, or DecryptDataKey. The Python implementation uses a simpler design where concrete keyrings (like KmsKeyring) directly implement the on_encrypt and on_decrypt methods without the intermediate abstraction layer. This reduces complexity for the initial implementation.
+
+---
+
+##= specification/s3-encryption/materials/s3-keyring.md#abstract-methods
+##= type=exception
+##% - The S3 Keyring MUST define an abstract method EncryptDataKey.
+
+Justification: The S3Keyring base class does not define abstract methods for GenerateDataKey, EncryptDataKey, or DecryptDataKey.
+The Python implementation uses a simpler design where concrete keyrings (like KmsKeyring) directly implement the on_encrypt and on_decrypt methods without the intermediate abstraction layer.
+
+---
+
+##= specification/s3-encryption/materials/s3-keyring.md#abstract-methods
+##= type=exception
+##% - The S3 Keyring MUST define an abstract method DecryptDataKey.
+
+Justification: The S3Keyring base class does not define abstract methods for GenerateDataKey, EncryptDataKey, or DecryptDataKey.
+The Python implementation uses a simpler design where concrete keyrings (like KmsKeyring) directly implement the on_encrypt and on_decrypt methods without the intermediate abstraction layer.
+
+---
+
+## S3 Keyring OnEncrypt Logic
+
+##= specification/s3-encryption/materials/s3-keyring.md#onencrypt
+##= type=exception
+##% If the Plaintext Data Key in the input EncryptionMaterials is null, the S3 Keyring MUST call the GenerateDataKey method using the materials.
+
+Justification: The S3Keyring base class does not implement this logic. The concrete KmsKeyring implementation directly calls KMS Encrypt in its on_encrypt method.
+The specification's pattern of checking for null plaintext and conditionally calling GenerateDataKey is not followed; instead, the implementation always generates a new key.
+
+---
+
+##= specification/s3-encryption/materials/s3-keyring.md#onencrypt
+##= type=exception
+##% If the materials returned from GenerateDataKey contain an EncryptedDataKey, the S3 Keyring MUST return the materials.
+
+Justification: Not applicable since the GenerateDataKey method pattern is not implemented. The KmsKeyring directly handles key generation and encryption in on_encrypt.
+
+---
+
+##= specification/s3-encryption/materials/s3-keyring.md#onencrypt
+##= type=exception
+##% If the materials returned from GenerateDataKey do not contain an EncryptedDataKey, the S3 Keyring MUST call the EncryptDataKey method using the materials.
+
+Justification: Not applicable since the GenerateDataKey and EncryptDataKey method pattern is not implemented.
+The KmsKeyring uses KMS GenerateDataKey which returns both plaintext and encrypted key in a single call.
+
+---
+
+## S3 Keyring OnDecrypt Validations
+
+##= specification/s3-encryption/materials/s3-keyring.md#ondecrypt
+##= type=exception
+##% The S3 Keyring MAY validate that the Key Provider ID of the Encrypted Data Key matches the expected default Key Provider ID value.
+
+Justification: This optional validation is not implemented.
+The Key Provider ID field is not used for anything in S3EC.
+
+---
+
+##= specification/s3-encryption/materials/s3-keyring.md#ondecrypt
+##= type=exception
+##% The S3 Keyring MUST call the DecryptDataKey method using the materials and add the resulting plaintext data key to the materials.
+
+Justification: The S3Keyring base class does not implement this logic.
+The concrete KmsKeyring implementation directly calls KMS Decrypt in its on_decrypt method rather than calling a separate DecryptDataKey method.
+This is consistent with the simplified design that doesn't use the abstract method pattern.
+
+---
diff --git a/docs/api.rst b/docs/api.rst
new file mode 100644
index 00000000..4611623b
--- /dev/null
+++ b/docs/api.rst
@@ -0,0 +1,35 @@
+API Reference
+=============
+
+Client
+------
+
+.. automodule:: s3_encryption
+ :members: S3EncryptionClient, S3EncryptionClientConfig
+
+Materials
+---------
+
+KMS Keyring
+~~~~~~~~~~~
+
+.. automodule:: s3_encryption.materials.kms_keyring
+ :members:
+
+Keyring Interface
+~~~~~~~~~~~~~~~~~
+
+.. automodule:: s3_encryption.materials.keyring
+ :members:
+
+Materials
+~~~~~~~~~
+
+.. automodule:: s3_encryption.materials.materials
+ :members:
+
+Exceptions
+----------
+
+.. automodule:: s3_encryption.exceptions
+ :members:
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 00000000..584e5145
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,38 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Sphinx configuration for Amazon S3 Encryption Client for Python."""
+
+project = "Amazon S3 Encryption Client for Python"
+copyright = "Amazon.com, Inc. or its affiliates"
+author = "AWS Crypto Tools"
+
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx.ext.napoleon",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.viewcode",
+]
+
+# Napoleon settings for Google-style docstrings
+napoleon_google_docstring = True
+napoleon_numpy_docstring = False
+
+# Autodoc settings
+autodoc_member_order = "bysource"
+autodoc_default_options = {
+ "members": True,
+ "undoc-members": False,
+ "show-inheritance": True,
+}
+
+# Intersphinx mappings
+intersphinx_mapping = {
+ "python": ("https://docs.python.org/3", None),
+ "boto3": ("https://boto3.amazonaws.com/v1/documentation/api/latest/", None),
+}
+
+# Theme
+html_theme = "sphinx_rtd_theme"
+
+# Exclude patterns
+exclude_patterns = ["_build"]
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 00000000..84dd359a
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,41 @@
+Amazon S3 Encryption Client for Python
+=======================================
+
+The Amazon S3 Encryption Client for Python provides client-side encryption
+for objects stored in Amazon S3. It wraps a standard boto3 S3 client and
+transparently encrypts objects on upload and decrypts them on download.
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents
+
+ api
+
+Getting Started
+---------------
+
+.. code-block:: python
+
+ import boto3
+ from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig
+ from s3_encryption.materials.kms_keyring import KmsKeyring
+
+ kms_client = boto3.client("kms", region_name="us-west-2")
+ keyring = KmsKeyring(kms_client, "arn:aws:kms:us-west-2:123456789012:alias/my-key")
+
+ s3_client = boto3.client("s3")
+ config = S3EncryptionClientConfig(keyring=keyring)
+ s3ec = S3EncryptionClient(s3_client, config)
+
+ # Encrypt and upload
+ s3ec.put_object(Bucket="my-bucket", Key="my-object", Body=b"secret data")
+
+ # Download and decrypt
+ response = s3ec.get_object(Bucket="my-bucket", Key="my-object")
+ plaintext = response["Body"].read()
+
+Indices and tables
+------------------
+
+* :ref:`genindex`
+* :ref:`modindex`
diff --git a/examples/__init__.py b/examples/__init__.py
new file mode 100644
index 00000000..f94fd12a
--- /dev/null
+++ b/examples/__init__.py
@@ -0,0 +1,2 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
diff --git a/examples/src/__init__.py b/examples/src/__init__.py
new file mode 100644
index 00000000..f94fd12a
--- /dev/null
+++ b/examples/src/__init__.py
@@ -0,0 +1,2 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
diff --git a/examples/src/delayed_auth_streaming_example.py b/examples/src/delayed_auth_streaming_example.py
new file mode 100644
index 00000000..5be4eb76
--- /dev/null
+++ b/examples/src/delayed_auth_streaming_example.py
@@ -0,0 +1,89 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""
+This example demonstrates streaming decryption with delayed authentication
+using the S3 Encryption Client.
+
+By default, the S3 Encryption Client buffers the entire ciphertext and verifies
+the authentication tag before releasing any plaintext. This is the safest mode,
+but requires holding the entire object in memory.
+
+With delayed authentication enabled, plaintext is released incrementally as it
+is decrypted, before the authentication tag has been verified. This allows
+processing large files without buffering the entire object in memory.
+
+Your application should still read the stream to completion. In the event that
+an error is thrown in the final read due to an invalid authentication tag,
+your application must be able to invalidate the associated data.
+
+WARNING: With delayed authentication, plaintext is released before it has been
+authenticated. An attacker could modify the ciphertext and the client would
+release tampered plaintext before detecting the modification. Only use this
+mode when you need to process files too large to buffer in memory and you
+understand the security implications.
+
+This example:
+1. Creates a KMS Keyring
+2. Configures the S3 Encryption Client with delayed authentication enabled
+3. Encrypts and uploads a large object to S3
+4. Streams the decrypted object back, reading it in chunks
+5. Verifies the decrypted content matches the original
+"""
+
+from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig
+from s3_encryption.exceptions import S3EncryptionClientSecurityError
+from s3_encryption.materials.kms_keyring import KmsKeyring
+
+# 10 MB of example data
+EXAMPLE_DATA: bytes = b"A" * (10 * 1024 * 1024)
+CHUNK_SIZE = 1024 * 1024 # 1 MB
+
+
+def delayed_auth_streaming_decrypt(
+ s3_client, kms_client, kms_key_id: str, bucket: str, key: str
+):
+ """Demonstrate streaming decryption with delayed authentication.
+
+ Args:
+ s3_client: boto3 S3 client.
+ kms_client: boto3 KMS client.
+ kms_key_id: KMS key ARN or alias to use for encryption/decryption.
+ bucket: S3 bucket name.
+ key: S3 object key.
+ """
+ # 1. Create a KMS Keyring.
+ keyring = KmsKeyring(kms_client=kms_client, kms_key_id=kms_key_id)
+
+ # 2. Configure the S3 Encryption Client with delayed authentication.
+ config = S3EncryptionClientConfig(
+ keyring=keyring,
+ enable_delayed_authentication=True,
+ )
+ s3ec = S3EncryptionClient(wrapped_s3_client=s3_client, config=config)
+
+ # 3. Encrypt and upload the object.
+ s3ec.put_object(Bucket=bucket, Key=key, Body=EXAMPLE_DATA)
+
+ # 4. Stream the decrypted object back in chunks.
+ # With delayed authentication, plaintext is released incrementally
+ # without buffering the entire object in memory.
+ response = s3ec.get_object(Bucket=bucket, Key=key)
+ body = response["Body"]
+
+ chunks = []
+ try:
+ while True:
+ chunk = body.read(CHUNK_SIZE)
+ if not chunk:
+ break
+ chunks.append(chunk)
+
+ plaintext = b"".join(chunks)
+
+ except S3EncryptionClientSecurityError:
+ # Authentication tag verification failed.
+ # Discard any plaintext released before the error.
+ raise
+
+ # 5. Verify the decrypted content matches the original.
+ assert plaintext == EXAMPLE_DATA, "Decrypted plaintext does not match original data"
diff --git a/examples/src/instruction_file_example.py b/examples/src/instruction_file_example.py
new file mode 100644
index 00000000..3c5db625
--- /dev/null
+++ b/examples/src/instruction_file_example.py
@@ -0,0 +1,59 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""
+This example demonstrates decrypting S3 objects that store their encryption
+metadata in instruction files rather than S3 object metadata.
+
+An instruction file is a companion S3 object that contains the encryption
+metadata (encrypted data key, IV, algorithm, etc.) as JSON. By default,
+the instruction file has the same key as the encrypted object with a
+".instruction" suffix appended.
+
+You can also use a custom instruction file suffix. This requires configuring
+the S3 Encryption Client with the matching suffix.
+
+NOTE: At this time, the S3 Encryption Client in Python ONLY supports decrypting
+(reading) with instruction files; encrypting with instruction files is not supported
+at this time.
+
+This example:
+1. Decrypts an object using the default instruction file suffix (".instruction")
+2. Decrypts the same object using a custom instruction file suffix
+"""
+
+from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig
+from s3_encryption.materials.kms_keyring import KmsKeyring
+
+
+def instruction_file_get(
+ s3_client, kms_client, kms_key_id: str, bucket: str, key: str, expected_plaintext: bytes
+):
+ """Demonstrate decrypting objects with default and custom instruction file suffixes.
+
+ Args:
+ s3_client: boto3 S3 client.
+ kms_client: boto3 KMS client.
+ kms_key_id: KMS key ARN or alias used to encrypt the object.
+ bucket: S3 bucket containing the encrypted object and instruction files.
+ key: S3 object key of the encrypted object.
+ expected_plaintext: Expected plaintext content for verification.
+ """
+ keyring = KmsKeyring(kms_client=kms_client, kms_key_id=kms_key_id)
+
+ # 1. Decrypt using the default instruction file suffix (".instruction").
+ # The client will fetch ".instruction" for the encryption metadata.
+ config = S3EncryptionClientConfig(keyring=keyring)
+ s3ec = S3EncryptionClient(wrapped_s3_client=s3_client, config=config)
+
+ response = s3ec.get_object(Bucket=bucket, Key=key)
+ plaintext = response["Body"].read()
+ assert plaintext == expected_plaintext, "Default suffix: decrypted plaintext does not match"
+
+ # 2. Decrypt while specifying the Instruction File Suffix
+ # InstructionFileSuffix is a per-request keyword argument on get_object,
+ # so the same client can use different suffixes per request.
+ response = s3ec.get_object(
+ Bucket=bucket, Key=key, InstructionFileSuffix=".custom-suffix-instruction"
+ )
+ plaintext = response["Body"].read()
+ assert plaintext == expected_plaintext, "Custom suffix: decrypted plaintext does not match"
diff --git a/examples/src/kms_keyring_put_get_example.py b/examples/src/kms_keyring_put_get_example.py
new file mode 100644
index 00000000..de6ef4e4
--- /dev/null
+++ b/examples/src/kms_keyring_put_get_example.py
@@ -0,0 +1,95 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""
+This example demonstrates a basic put/get roundtrip using the S3 Encryption Client
+with a KMS Keyring.
+
+The KMS Keyring uses a symmetric KMS key to generate and decrypt data keys.
+The S3 Encryption Client encrypts the object before uploading to S3 and decrypts
+it on download, so the data is protected at rest.
+
+This example:
+1. Creates a KMS Keyring with the provided KMS key ID
+2. Wraps a boto3 S3 client with the S3 Encryption Client
+3. Creates an encryption context bound to the S3 bucket and key
+4. Puts an encrypted object to S3
+5. Gets and decrypts the object from S3
+6. Verifies the decrypted plaintext matches the original
+
+Here is an example KMS Key Policy statement that would validate the
+Encryption Context used in this example::
+
+ Sid: RestrictToEncryptionContextBucket
+ Effect: Allow
+ Principal:
+ AWS: "arn:aws:iam:::role/"
+ Action:
+ - kms:GenerateDataKey
+ - kms:Decrypt
+ Resource: "*"
+ Condition:
+ StringEquals:
+ "kms:EncryptionContext:aws-s3-bucket": ""
+"""
+
+from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig, S3EncryptionClientError
+from s3_encryption.materials.kms_keyring import KmsKeyring
+
+EXAMPLE_DATA: bytes = b"Hello, S3 Encryption Client!"
+
+
+def kms_keyring_put_get(s3_client, kms_client, kms_key_id: str, bucket: str, key: str):
+ """Demonstrate an encrypt/decrypt cycle using a KMS Keyring with S3.
+
+ Args:
+ s3_client: boto3 S3 client.
+ kms_client: boto3 KMS client.
+ kms_key_id: KMS key ARN or alias to use for encryption/decryption.
+ bucket: S3 bucket name.
+ key: S3 object key.
+ """
+ # 1. Create a KMS Keyring.
+ keyring = KmsKeyring(kms_client=kms_client, kms_key_id=kms_key_id)
+
+ # 2. Wrap the S3 client with the S3 Encryption Client.
+ # The default commitment policy is REQUIRE_ENCRYPT_REQUIRE_DECRYPT,
+ # which enforces key-committing algorithm suites on both encrypt and decrypt.
+ config = S3EncryptionClientConfig(keyring=keyring)
+ s3ec = S3EncryptionClient(wrapped_s3_client=s3_client, config=config)
+
+ # 3. Create an encryption context.
+ # The encryption context is a set of key-value pairs that are bound to the ciphertext.
+ # Including the bucket and key ensures the ciphertext is tied to this specific S3 object.
+ # This will also be visible to KMS when evaluating key policies.
+ # See the example KMS Key Policy in this module's docstring.
+ # The encryption context is optional, but strongly recommended.
+ encryption_context = {
+ "aws-s3-bucket": bucket,
+ "aws-s3-key": key,
+ }
+
+ # 4. Put an encrypted object.
+ s3ec.put_object(Bucket=bucket, Key=key, Body=EXAMPLE_DATA, EncryptionContext=encryption_context)
+
+ # 5. Get and decrypt the object.
+ # If you specified an encryption context during encryption,
+ # you must provide the same encryption context during decryption.
+ response = s3ec.get_object(Bucket=bucket, Key=key, EncryptionContext=encryption_context)
+ plaintext = response["Body"].read()
+
+ # 6. Optional Verify the decrypted plaintext matches the original.
+ assert plaintext == EXAMPLE_DATA, "Decrypted plaintext does not match original data"
+
+ # However, if the Encryption Context is not present at decryption time, then decryption will fail
+ failed = False
+ try:
+ _ = s3ec.get_object(
+ Bucket=bucket, Key=key,
+ # Incomplete Encryption Context
+ EncryptionContext={"aws-s3-bucket": bucket})
+ except S3EncryptionClientError as e:
+ failed = True
+ assert hasattr(e, "kwargs")
+ assert e.kwargs.get("msg") is not None
+ assert e.kwargs.get("msg") == "Provided encryption context does not match information retrieved from S3"
+ assert failed
diff --git a/examples/src/legacy_decrypt_example.py b/examples/src/legacy_decrypt_example.py
new file mode 100644
index 00000000..cbccc96b
--- /dev/null
+++ b/examples/src/legacy_decrypt_example.py
@@ -0,0 +1,60 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""
+This example demonstrates how to decrypt legacy S3 objects that were encrypted
+using older versions of the S3 Encryption Client (V1 or V2).
+
+Legacy objects use the KmsV1 wrapping algorithm and may use unauthenticated
+content encryption (AES-CBC). To decrypt these objects, you must:
+1. Enable legacy wrapping algorithms on the KMS Keyring
+2. Enable legacy unauthenticated modes on the S3 Encryption Client config
+3. Use a commitment policy that allows non-key-committing algorithm suites
+
+This example:
+1. Creates a KMS Keyring with legacy wrapping algorithms enabled
+2. Configures the S3 Encryption Client with legacy decryption support
+3. Decrypts a legacy V1 object from S3
+4. Verifies the decrypted plaintext matches the expected content
+"""
+
+from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig
+from s3_encryption.materials.kms_keyring import KmsKeyring
+from s3_encryption.materials.materials import CommitmentPolicy
+
+
+def decrypt_legacy_object(s3_client, kms_client, kms_key_id: str, bucket: str, key: str):
+ """Decrypt a legacy S3 object encrypted by an older S3 Encryption Client.
+
+ Args:
+ s3_client: boto3 S3 client.
+ kms_client: boto3 KMS client.
+ kms_key_id: KMS key ARN or alias used to encrypt the object.
+ bucket: S3 bucket name.
+ key: S3 object key.
+
+ Returns:
+ Decrypted plaintext bytes.
+ """
+ # 1. Create a KMS Keyring with legacy wrapping algorithms enabled.
+ # This allows the keyring to decrypt data keys wrapped using the KmsV1 mode,
+ # which older S3 Encryption Clients used.
+ keyring = KmsKeyring(
+ kms_client=kms_client,
+ kms_key_id=kms_key_id,
+ enable_legacy_wrapping_algorithms=True,
+ )
+
+ # 2. Configure the S3 Encryption Client for legacy decryption.
+ # - enable_legacy_unauthenticated_modes: allows decryption of AES-CBC content
+ # - REQUIRE_ENCRYPT_ALLOW_DECRYPT: new objects are encrypted with key-committing
+ # algorithm suites, while still allowing decryption of legacy objects
+ config = S3EncryptionClientConfig(
+ keyring=keyring,
+ enable_legacy_unauthenticated_modes=True,
+ commitment_policy=CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT,
+ )
+ s3ec = S3EncryptionClient(wrapped_s3_client=s3_client, config=config)
+
+ # 3. Decrypt the legacy object.
+ response = s3ec.get_object(Bucket=bucket, Key=key)
+ return response["Body"].read()
diff --git a/examples/test/__init__.py b/examples/test/__init__.py
new file mode 100644
index 00000000..f94fd12a
--- /dev/null
+++ b/examples/test/__init__.py
@@ -0,0 +1,2 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
diff --git a/examples/test/test_i_delayed_auth_streaming_example.py b/examples/test/test_i_delayed_auth_streaming_example.py
new file mode 100644
index 00000000..d087789c
--- /dev/null
+++ b/examples/test/test_i_delayed_auth_streaming_example.py
@@ -0,0 +1,29 @@
+# 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
+
+from ..src.delayed_auth_streaming_example import delayed_auth_streaming_decrypt
+
+pytestmark = [pytest.mark.examples]
+
+BUCKET = "s3ec-python-github-test-bucket"
+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(
+ s3_client=s3_client,
+ kms_client=kms_client,
+ kms_key_id=KMS_KEY_ID,
+ bucket=BUCKET,
+ key=key,
+ )
+ s3_client.delete_object(Bucket=BUCKET, Key=key)
diff --git a/examples/test/test_i_instruction_file_example.py b/examples/test/test_i_instruction_file_example.py
new file mode 100644
index 00000000..938147f8
--- /dev/null
+++ b/examples/test/test_i_instruction_file_example.py
@@ -0,0 +1,27 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Test suite for the instruction file example."""
+
+import boto3
+import pytest
+
+from ..src.instruction_file_example import instruction_file_get
+
+pytestmark = [pytest.mark.examples]
+
+BUCKET = "s3ec-static-test-objects"
+KEY = "static-v3-instruction-file-from-java-v4"
+KMS_KEY_ID = "arn:aws:kms:us-west-2:370957321024:key/a3889cd9-99eb-4138-a93a-aea9d52ec2ef"
+
+
+def test_instruction_file_get():
+ s3_client = boto3.client("s3", region_name="us-west-2")
+ kms_client = boto3.client("kms", region_name="us-west-2")
+ instruction_file_get(
+ s3_client=s3_client,
+ kms_client=kms_client,
+ kms_key_id=KMS_KEY_ID,
+ bucket=BUCKET,
+ key=KEY,
+ expected_plaintext=KEY.encode("utf-8"),
+ )
diff --git a/examples/test/test_i_kms_keyring_put_get_example.py b/examples/test/test_i_kms_keyring_put_get_example.py
new file mode 100644
index 00000000..bff0e76f
--- /dev/null
+++ b/examples/test/test_i_kms_keyring_put_get_example.py
@@ -0,0 +1,29 @@
+# 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
+
+from ..src.kms_keyring_put_get_example import kms_keyring_put_get
+
+pytestmark = [pytest.mark.examples]
+
+BUCKET = "s3ec-python-github-test-bucket"
+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(
+ s3_client=s3_client,
+ kms_client=kms_client,
+ kms_key_id=KMS_KEY_ID,
+ 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
new file mode 100644
index 00000000..b072d561
--- /dev/null
+++ b/examples/test/test_i_legacy_decrypt_example.py
@@ -0,0 +1,28 @@
+# 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
+
+from ..src.legacy_decrypt_example import decrypt_legacy_object
+
+pytestmark = [pytest.mark.examples]
+
+BUCKET = "s3ec-static-test-objects"
+KEY = "static-v1-instruction-file-from-java-v1"
+KMS_KEY_ID = "arn:aws:kms:us-west-2:370957321024:key/a3889cd9-99eb-4138-a93a-aea9d52ec2ef"
+
+
+def test_decrypt_legacy_object():
+ s3_client = boto3.client("s3", region_name="us-west-2")
+ kms_client = boto3.client("kms", region_name="us-west-2")
+ plaintext = decrypt_legacy_object(
+ s3_client=s3_client,
+ kms_client=kms_client,
+ kms_key_id=KMS_KEY_ID,
+ bucket=BUCKET,
+ key=KEY,
+ )
+ assert plaintext == KEY.encode("utf-8")
+ # Avoid deleting the static object, it is used in the integration tests
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..abad14d0
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,73 @@
+[project]
+name = "amazon-s3-encryption-client-python"
+version = "3.0.0"
+description = "This library provides an S3 client that supports client-side encryption."
+authors = [
+ {name = "AWS Crypto Tools", email = "aws-crypto-tools@amazon.com"}
+]
+license = {text = "Apache-2.0"}
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+ "boto3>=1.43.6,<2",
+ "cryptography>=48.0.0,<49",
+ "attrs>=26.1.0,<27",
+]
+
+[project.optional-dependencies]
+test = [
+ "pytest>=9.0.3",
+ "pytest-cov>=7.1.0",
+]
+dev = [
+ "ruff>=0.15.12",
+ "boto3-stubs~=1.43.6",
+]
+docs = [
+ "sphinx>=7.0,<8",
+ "sphinx-rtd-theme>=2.0,<3",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/s3_encryption"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py310"
+exclude = [".git", "__pycache__", "build", "dist"]
+
+[tool.ruff.lint]
+# Enable all rules by default, then configure specific rule settings below
+select = ["E", "F", "W", "I", "N", "D", "UP", "B", "A", "C4", "PT", "RET", "SIM", "ARG", "ERA"]
+ignore = [
+ "ARG002", # Allow unused method arguments (e.g., **kwargs for API compatibility)
+ "E501", # Line too long - Duvet annotations require long specification paths
+]
+
+[tool.ruff.lint.pydocstyle]
+convention = "google"
+
+[tool.ruff.lint.mccabe]
+max-complexity = 10
+
+[tool.ruff.lint.isort]
+known-first-party = ["s3_encryption"]
+
+[tool.ruff.lint.per-file-ignores]
+"test/**/*.py" = ["D100", "D101", "D102", "D103", "D104", "E501"]
+"src/s3_encryption/pipelines.py" = ["E501"]
+
+[tool.coverage.run]
+source = ["src/s3_encryption"]
+
+[tool.coverage.report]
+show_missing = true
+
+[tool.pytest.ini_options]
+markers = [
+ "examples: S3 Encryption Client example tests",
+]
diff --git a/release-validation/validate.py b/release-validation/validate.py
new file mode 100644
index 00000000..c9af1ef3
--- /dev/null
+++ b/release-validation/validate.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Post-release validation: install the published package and do a round-trip.
+
+This script is run after publishing to TestPyPI or PyPI to verify that
+the released artifact works correctly for consumers.
+"""
+
+import os
+import sys
+import uuid
+
+import boto3
+
+from s3_encryption import S3EncryptionClient, S3EncryptionClientConfig
+from s3_encryption._utils import _PACKAGE_VERSION
+from s3_encryption.materials.kms_keyring import KmsKeyring
+
+BUCKET = os.environ.get("CI_S3_BUCKET", "s3ec-python-github-test-bucket")
+KMS_KEY_ID = os.environ.get(
+ "CI_KMS_KEY_ALIAS", "arn:aws:kms:us-west-2:370957321024:alias/S3EC-Python-Github-KMS-Key"
+)
+REGION = "us-west-2"
+
+
+def main():
+ print(f"Validating amazon-s3-encryption-client-python v{_PACKAGE_VERSION}")
+
+ kms_client = boto3.client("kms", region_name=REGION)
+ keyring = KmsKeyring(kms_client, KMS_KEY_ID)
+ s3_client = boto3.client("s3", region_name=REGION)
+ config = S3EncryptionClientConfig(keyring=keyring)
+ s3ec = S3EncryptionClient(s3_client, config)
+
+ key = f"release-validation/{uuid.uuid4()}"
+ plaintext = b"Release validation round-trip test"
+
+ # Put
+ print(f" Encrypting and uploading to s3://{BUCKET}/{key}")
+ s3ec.put_object(Bucket=BUCKET, Key=key, Body=plaintext)
+
+ # Get
+ print(f" Downloading and decrypting from s3://{BUCKET}/{key}")
+ response = s3ec.get_object(Bucket=BUCKET, Key=key)
+ result = response["Body"].read()
+
+ assert result == plaintext, f"Round-trip failed: expected {plaintext!r}, got {result!r}"
+
+ # Cleanup
+ s3_client.delete_object(Bucket=BUCKET, Key=key)
+
+ print(" Round-trip validation passed!")
+ print(f" Version: {_PACKAGE_VERSION}")
+ print(f" User-Agent includes: S3ECPy/{_PACKAGE_VERSION}")
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000..423d8c8d
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,12 @@
+# Main dependencies
+boto3>=1.37.2
+cryptography>=45.0.6
+attrs>=25.1.0
+
+# Test dependencies
+pytest>=8.4.1
+
+# Development dependencies
+black>=24.3.0
+ruff>=0.3.0
+isort>=5.13.2
diff --git a/specification b/specification
new file mode 160000
index 00000000..7edabc2a
--- /dev/null
+++ b/specification
@@ -0,0 +1 @@
+Subproject commit 7edabc2a69890e1119c49f948a086638182369a4
diff --git a/src/s3_encryption/__init__.py b/src/s3_encryption/__init__.py
new file mode 100644
index 00000000..ea6f1dc8
--- /dev/null
+++ b/src/s3_encryption/__init__.py
@@ -0,0 +1,866 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Top-level S3 Encryption Client v4 for Python package."""
+
+import io
+import os
+import threading
+
+from attrs import define, field
+from botocore.exceptions import ClientError
+from botocore.response import StreamingBody
+
+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
+from .materials.crypto_materials_manager import (
+ AbstractCryptoMaterialsManager,
+ DefaultCryptoMaterialsManager,
+)
+from .materials.keyring import AbstractKeyring
+from .materials.materials import AlgorithmSuite, CommitmentPolicy
+from .pipelines import (
+ GetEncryptedObjectPipeline,
+ MultipartUploadPipeline,
+ PutEncryptedObjectPipeline,
+)
+
+S3_METADATA_PREFIX = "x-amz-meta-"
+
+# Default multipart threshold and chunk size (same as boto3 defaults)
+_DEFAULT_MULTIPART_THRESHOLD = 8 * 1024 * 1024 # 8 MB
+_DEFAULT_MULTIPART_CHUNKSIZE = 8 * 1024 * 1024 # 8 MB
+_MIN_MULTIPART_PART_SIZE = 5 * 1024 * 1024 # 5 MB — S3 minimum for non-final parts
+
+# Thread-local context attribute names
+_CTX_ENCRYPTION_CONTEXT = "encryption_context"
+_CTX_BUCKET = "bucket"
+_CTX_KEY = "key"
+_CTX_S3_CLIENT = "s3_client"
+_CTX_INSTRUCTION_FILE_MODE = "instruction_file_mode"
+_CTX_INSTRUCTION_FILE_SUFFIX = "instruction_file_suffix"
+
+# Attributes to clean up after get_object completes
+# (s3_client is intentionally excluded — it is not request-scoped)
+_GET_OBJECT_CLEANUP_ATTRS = (
+ _CTX_ENCRYPTION_CONTEXT,
+ _CTX_BUCKET,
+ _CTX_KEY,
+ _CTX_INSTRUCTION_FILE_SUFFIX,
+)
+
+
+@define
+class S3EncryptionClientConfig:
+ """Configuration for the S3 Encryption Client.
+
+ Attributes:
+ keyring: Keyring used for encrypting/decrypting data keys.
+ encryption_algorithm: Algorithm suite for encryption. Defaults to
+ ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY (V3 key-committing).
+ commitment_policy: Key commitment policy for encryption and decryption.
+ Defaults to REQUIRE_ENCRYPT_REQUIRE_DECRYPT.
+ enable_legacy_unauthenticated_modes: If True, allow decryption of objects
+ encrypted with legacy CBC algorithm suites. Defaults to False.
+ cmm: Crypto materials manager. Defaults to a DefaultCryptoMaterialsManager
+ wrapping the provided keyring.
+ enable_delayed_authentication: If True, release plaintext from streams
+ before GCM tag verification. Defaults to False. Has no effect for
+ CBC encrypted ciphertext, which is always streamed as there is no
+ authentication tag.
+ instruction_file_config: Configuration for instruction file behavior.
+ Defaults to InstructionFileConfig() which enables instruction file
+ reads on GetObject.
+
+ Raises:
+ S3EncryptionClientError: If the encryption algorithm is legacy, or if
+ the algorithm suite is incompatible with the commitment policy.
+ """
+
+ keyring: AbstractKeyring
+ encryption_algorithm: AlgorithmSuite = field(
+ default=AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ )
+ commitment_policy: CommitmentPolicy = field(
+ default=CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT
+ )
+ ##= specification/s3-encryption/client.md#enable-legacy-unauthenticated-modes
+ ##% The S3EC MUST support the option to enable or disable legacy unauthenticated modes (content encryption algorithms).
+ ##= specification/s3-encryption/client.md#enable-legacy-unauthenticated-modes
+ ##% The option to enable legacy unauthenticated modes MUST be set to false by default.
+ enable_legacy_unauthenticated_modes: bool = field(default=False)
+ cmm: AbstractCryptoMaterialsManager = field()
+
+ ##= specification/s3-encryption/client.md#enable-delayed-authentication
+ ##= type=implementation
+ ##% The S3EC MUST support the option to enable or disable Delayed Authentication mode.
+
+ ##= specification/s3-encryption/client.md#enable-delayed-authentication
+ ##= type=implication
+ ##% Delayed Authentication mode MUST be set to false by default.
+ enable_delayed_authentication: bool = field(default=False)
+
+ instruction_file_config: InstructionFileConfig = field(factory=InstructionFileConfig)
+
+ @cmm.default
+ def _default_cmm_for_keyring(self):
+ return DefaultCryptoMaterialsManager(self.keyring)
+
+ ##= specification/s3-encryption/client.md#encryption-algorithm
+ ##% The S3EC MUST validate that the configured encryption algorithm is not legacy.
+ ##= specification/s3-encryption/client.md#encryption-algorithm
+ ##% If the configured encryption algorithm is legacy, then the S3EC MUST throw an exception.
+ ##= specification/s3-encryption/client.md#key-commitment
+ ##% The S3EC MUST validate the configured Encryption Algorithm against the provided key commitment policy.
+ ##= specification/s3-encryption/client.md#key-commitment
+ ##% If the configured Encryption Algorithm is incompatible with the key commitment policy, then it MUST throw an exception.
+ def __attrs_post_init__(self):
+ """Validate algorithm suite and commitment policy configuration."""
+ if self.encryption_algorithm.is_legacy:
+ raise S3EncryptionClientError(
+ f"Cannot configure S3 Encryption Client with legacy algorithm suite "
+ f"{self.encryption_algorithm.name}. Legacy algorithm suites are only "
+ f"supported for decryption (and enable_legacy_unauthenticated_modes is True)."
+ )
+
+ ##= specification/s3-encryption/key-commitment.md#commitment-policy
+ ##% When the commitment policy is REQUIRE_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST only encrypt using an algorithm suite which supports key commitment.
+ ##= specification/s3-encryption/key-commitment.md#commitment-policy
+ ##% When the commitment policy is REQUIRE_ENCRYPT_REQUIRE_DECRYPT, the S3EC MUST only encrypt using an algorithm suite which supports key commitment.
+ if (
+ self.commitment_policy
+ in (
+ CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT,
+ CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT,
+ )
+ and not self.encryption_algorithm.supports_key_commitment
+ ):
+ raise S3EncryptionClientError(
+ f"Commitment policy {self.commitment_policy.name} requires a key-committing "
+ f"algorithm suite, but {self.encryption_algorithm.name} does not support key commitment."
+ )
+
+ ##= specification/s3-encryption/key-commitment.md#commitment-policy
+ ##% When the commitment policy is FORBID_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST NOT encrypt using an algorithm suite which supports key commitment.
+ if (
+ self.commitment_policy == CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT
+ and self.encryption_algorithm.supports_key_commitment
+ ):
+ raise S3EncryptionClientError(
+ f"Commitment policy {self.commitment_policy.name} forbids key-committing "
+ f"algorithm suites, but {self.encryption_algorithm.name} supports key commitment."
+ )
+
+
+class S3EncryptionClientPlugin:
+ """Plugin that adds encryption/decryption capabilities to a boto3 S3 client.
+
+ This plugin uses boto3's event system to intercept put_object and get_object
+ calls to provide transparent encryption and decryption of S3 objects.
+ """
+
+ def __init__(self, config: S3EncryptionClientConfig):
+ """Initialize the plugin with encryption configuration.
+
+ Args:
+ config: S3EncryptionClientConfig containing keyring and CMM
+ """
+ self.config = config
+ self._context = threading.local()
+
+ def on_put_object_before_call(self, params, **kwargs):
+ """Event handler for before-call.s3.PutObject.
+
+ This handler encrypts the body after serialization but before the request is sent.
+
+ Args:
+ params: Dictionary of parameters for the PutObject call (after serialization)
+ **kwargs: Additional event arguments
+ """
+ if getattr(self._context, _CTX_INSTRUCTION_FILE_MODE, False):
+ raise S3EncryptionClientError(
+ "Instruction file mode is exclusively for reading instruction files "
+ "and not supported in put_object!"
+ )
+ # At this point, boto3 has already serialized the Body
+ # Extract the serialized body from the request
+ body = params.get("body")
+ if body is None:
+ body_bytes = b""
+ elif isinstance(body, bytes):
+ body_bytes = body
+ elif hasattr(body, "read"):
+ # It's a file-like object (BytesIO, etc.)
+ # TODO(streaming): Add support for streaming encryption without reading entire body
+ # into memory
+ body_bytes = body.read()
+ else:
+ # Unexpected body type - should not happen as boto3 validates before this point
+ raise S3EncryptionClientError("Unexpected type of body parameter!")
+
+ encryption_context = getattr(self._context, _CTX_ENCRYPTION_CONTEXT, None)
+
+ pipeline = PutEncryptedObjectPipeline(self.config.cmm, self.config.encryption_algorithm)
+ encrypted_data, encryption_metadata = pipeline.encrypt(
+ body_bytes,
+ encryption_context=encryption_context,
+ )
+
+ params["body"] = encrypted_data
+
+ headers = safe_get_dict(params, "headers")
+
+ # Add encryption metadata to headers
+ if encryption_metadata:
+ for key, value in encryption_metadata.items():
+ # Add as S3 metadata headers
+ header_key = f"{S3_METADATA_PREFIX}{key}"
+ headers[header_key] = value
+
+ params["headers"] = headers
+
+ def on_get_object_after_call(self, parsed, **kwargs):
+ """Event handler for after-call.s3.GetObject.
+
+ This handler decrypts the body after the response is received from S3.
+
+ Args:
+ parsed: Dictionary containing the parsed response
+ **kwargs: Additional event arguments (includes 'params' with request parameters)
+ """
+ # Check if plaintext mode is enabled via thread-local flag
+ if getattr(self._context, _CTX_INSTRUCTION_FILE_MODE, False):
+ self.process_instruction_file(parsed)
+ return
+
+ # Get encryption context from thread-local storage (set by get_object wrapper)
+ encryption_context = getattr(self._context, _CTX_ENCRYPTION_CONTEXT, None)
+
+ # If Body is None, the S3 request failed (e.g., NoSuchKey).
+ # Return early and let boto3 raise the original error.
+ if parsed.get("Body", None) is None:
+ return
+
+ # The parsed response already has the Body as a StreamingBody
+ # We need to read it, decrypt it, and replace it
+
+ # content_length is going to the cipher-text's content length
+ content_length = parsed.get("ContentLength")
+ if content_length is None:
+ obj_key = getattr(self._context, _CTX_KEY, None)
+ raise S3EncryptionClientError(
+ f"S3 response is missing ContentLength and is invalid. Key: {obj_key}"
+ )
+ # Create a response dict that matches what the pipeline expects
+ response = {
+ "Body": parsed.get("Body"),
+ "Metadata": safe_get_dict(parsed, "Metadata"),
+ "ContentLength": content_length,
+ }
+
+ # Create a pipeline and decrypt the data
+ pipeline = GetEncryptedObjectPipeline(
+ self.config.cmm,
+ commitment_policy=self.config.commitment_policy,
+ s3_client=getattr(self._context, _CTX_S3_CLIENT, None),
+ enable_legacy_unauthenticated_modes=self.config.enable_legacy_unauthenticated_modes,
+ instruction_file_config=self.config.instruction_file_config,
+ )
+ decrypted_data = pipeline.decrypt(
+ response,
+ instruction_suffix=getattr(self._context, _CTX_INSTRUCTION_FILE_SUFFIX, ".instruction"),
+ enable_delayed_authentication=self.config.enable_delayed_authentication,
+ encryption_context=encryption_context,
+ bucket=getattr(self._context, _CTX_BUCKET, None),
+ key=getattr(self._context, _CTX_KEY, None),
+ )
+
+ # Replace body with decrypting stream
+ parsed["Body"] = decrypted_data
+
+ def process_instruction_file(self, parsed):
+ """Process instruction file in plaintext mode.
+
+ Validates the instruction file marker, parses the JSON body,
+ and updates the response metadata with parsed content.
+
+ Args:
+ parsed: Dictionary containing the parsed response
+ """
+ instruction_key = getattr(self._context, _CTX_KEY, None)
+
+ body = parsed.get("Body", None)
+ if body is None:
+ raise S3EncryptionClientError(
+ f"Instruction file body is empty for key: {instruction_key}"
+ )
+
+ # In plaintext mode, parse instruction file and append to metadata
+ existing_metadata = safe_get_dict(parsed, "Metadata")
+ instruction_data = body.read()
+ instruction_metadata = parse_instruction_file(instruction_data, instruction_key)
+
+ # Append parsed instruction file content to existing metadata
+ existing_metadata.update(instruction_metadata)
+ parsed["Metadata"] = existing_metadata
+
+ # Clear the body since instruction files shouldn't return body content
+ stream = io.BytesIO(b"")
+ streaming_body = StreamingBody(stream, 0)
+ 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.
+
+ This client wraps a boto3 S3 client and provides encryption and decryption
+ capabilities for S3 objects using the configured keyring and crypto materials manager.
+
+ The encryption/decryption is implemented using boto3's event system, registering
+ handlers for before-call and after-call events.
+ """
+
+ wrapped_s3_client = field()
+ config: S3EncryptionClientConfig = field()
+ _plugin: S3EncryptionClientPlugin = field(init=False)
+ # Each upload gets its own pipeline with independent cipher state, keyed by UploadId.
+ # Access is protected by a lock for thread safety across all Python runtimes.
+ _multipart_uploads: dict = field(init=False, factory=dict)
+ _multipart_lock: threading.Lock = field(init=False, factory=threading.Lock)
+
+ def __attrs_post_init__(self):
+ """Install the encryption plugin on the wrapped client using boto3 events."""
+ # Create the plugin
+ object.__setattr__(self, "_plugin", S3EncryptionClientPlugin(self.config))
+
+ # 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)
+ 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,
+ list_objects_v2, etc.).
+ """
+ return getattr(self.wrapped_s3_client, name)
+
+ def put_object(self, **kwargs):
+ """Encrypt and upload an object to S3.
+
+ This method encrypts the provided object body before uploading it to S3.
+ It handles the encryption process using the configured crypto materials manager.
+
+ Args:
+ **kwargs: Arguments to pass to the S3 client's put_object method.
+ Must include Bucket and Key parameters.
+ Body parameter is optional; if not provided, an empty object is uploaded.
+ May include EncryptionContext for additional authenticated data.
+
+ Returns:
+ The response from the S3 client's put_object method.
+
+ Raises:
+ S3EncryptionClientError: Any problem with encryption, including if
+ the Body parameter has an invalid type.
+ """
+ # 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
+
+ try:
+ return self.wrapped_s3_client.put_object(**kwargs)
+ except S3EncryptionClientError:
+ # Re-raise our own exceptions without wrapping
+ raise
+ except Exception as e:
+ raise S3EncryptionClientError(f"Failed to encrypt object: {str(e)}") from e
+ finally:
+ # Clean up thread-local storage
+ if hasattr(self._plugin._context, _CTX_ENCRYPTION_CONTEXT):
+ delattr(self._plugin._context, _CTX_ENCRYPTION_CONTEXT)
+
+ ##= specification/s3-encryption/client.md#required-api-operations
+ ##% - DeleteObject MUST be implemented by the S3EC.
+ def delete_object(self, **kwargs):
+ """Delete an object and its associated instruction file from S3.
+
+ Args:
+ **kwargs: Arguments to pass to the S3 client's delete_object method.
+ Must include Bucket and Key parameters.
+ May include InstructionFileSuffix to override the default
+ ".instruction" suffix for instruction file deletion.
+
+ Returns:
+ The response from the S3 client's delete_object call for the object.
+
+ Raises:
+ S3EncryptionClientError: If the delete operation fails.
+ """
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ ##= type=implementation
+ ##% The default Instruction File behavior uses the same S3 object key
+ ##% as its associated object suffixed with ".instruction".
+ instruction_file_suffix = kwargs.pop("InstructionFileSuffix", ".instruction")
+
+ try:
+ ##= specification/s3-encryption/client.md#required-api-operations
+ ##= type=implementation
+ ##% - DeleteObject MUST delete the given object key.
+ response = self.wrapped_s3_client.delete_object(**kwargs)
+
+ ##= specification/s3-encryption/client.md#required-api-operations
+ ##= type=implementation
+ ##% - DeleteObject MUST delete the associated instruction file
+ ##% using the default instruction file suffix.
+ if not self.config.instruction_file_config.disable_delete_object:
+ instruction_key = kwargs["Key"] + instruction_file_suffix
+ self.wrapped_s3_client.delete_object(Bucket=kwargs["Bucket"], Key=instruction_key)
+
+ return response
+ except S3EncryptionClientError:
+ raise
+ except Exception as e:
+ raise S3EncryptionClientError(f"Failed to delete object: {str(e)}") from e
+
+ ##= specification/s3-encryption/client.md#required-api-operations
+ ##% - DeleteObjects MUST be implemented by the S3EC.
+ def delete_objects(self, **kwargs):
+ """Delete multiple objects and their associated instruction files from S3.
+
+ 2 requests are issued, one for the objects, and one for the instruction files.
+ If either requests fail, the operation fails, and maybe tried again to clean up any missed files.
+
+ Args:
+ **kwargs: Arguments to pass to the S3 client's delete_objects method.
+ Must include Bucket and Delete (with Objects list) parameters.
+ May include InstructionFileSuffix to override the default
+ ".instruction" suffix for instruction file deletion.
+
+ Returns:
+ The response from the S3 client's delete_objects call for the objects.
+
+ Raises:
+ S3EncryptionClientError: If either delete operations fails.
+ """
+ instruction_file_suffix = kwargs.pop("InstructionFileSuffix", ".instruction")
+
+ try:
+ ##= specification/s3-encryption/client.md#required-api-operations
+ ##= type=implementation
+ ##% - DeleteObjects MUST delete each of the given objects.
+ response = self.wrapped_s3_client.delete_objects(**kwargs)
+
+ ##= specification/s3-encryption/client.md#required-api-operations
+ ##= type=implementation
+ ##% - DeleteObjects MUST delete each of the corresponding instruction files
+ ##% using the default instruction file suffix.
+ if not self.config.instruction_file_config.disable_delete_objects:
+ instruction_objects = [
+ {"Key": obj["Key"] + instruction_file_suffix}
+ for obj in kwargs["Delete"]["Objects"]
+ ]
+ self.wrapped_s3_client.delete_objects(
+ Bucket=kwargs["Bucket"], Delete={"Objects": instruction_objects}
+ )
+
+ return response
+ except S3EncryptionClientError:
+ raise
+ except Exception as e:
+ raise S3EncryptionClientError(f"Failed to delete objects: {str(e)}") from e
+
+ def get_object(self, **kwargs):
+ """Download and decrypt an object from S3.
+
+ This method downloads an encrypted object from S3 and decrypts it
+ using the configured crypto materials manager.
+
+ Args:
+ **kwargs: Arguments to pass to the S3 client's get_object method.
+ May include EncryptionContext if it was used during encryption.
+ May include InstructionFileSuffix to override the default
+ ".instruction" suffix for instruction file lookups.
+
+ Returns:
+ The response from the S3 client's get_object method with the Body
+ replaced with a StreamingBody containing the decrypted data.
+
+ Raises:
+ S3EncryptionClientError: If decryption fails or the object is not properly encrypted.
+ """
+ # Ranged gets are not supported — decryption requires the full ciphertext.
+ if "Range" in kwargs:
+ raise S3EncryptionClientError(
+ "Ranged gets are currently not supported by the S3 Encryption Client for Python."
+ )
+
+ # Extract EncryptionContext if provided (not a standard S3 parameter)
+ encryption_context = kwargs.pop("EncryptionContext", None)
+ _validate_encryption_context(encryption_context)
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ ##= type=implementation
+ ##% The S3EC SHOULD support providing a custom Instruction File suffix
+ ##% on GetObject requests, regardless of whether or not re-encryption is supported.
+
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ ##= type=implementation
+ ##% The default Instruction File behavior uses the same S3 object key
+ ##% as its associated object suffixed with ".instruction".
+ instruction_file_suffix = kwargs.pop("InstructionFileSuffix", ".instruction")
+
+ # Store encryption context in thread-local storage for the event handler
+ setattr(self._plugin._context, _CTX_ENCRYPTION_CONTEXT, encryption_context)
+ setattr(self._plugin._context, _CTX_INSTRUCTION_FILE_SUFFIX, instruction_file_suffix)
+ # Store wrapped client in thread-local storage for
+ # the event handler to fetch instruction files
+ setattr(self._plugin._context, _CTX_S3_CLIENT, self.wrapped_s3_client)
+ setattr(self._plugin._context, _CTX_BUCKET, kwargs.get("Bucket"))
+ setattr(self._plugin._context, _CTX_KEY, kwargs.get("Key"))
+
+ try:
+ return self.wrapped_s3_client.get_object(**kwargs)
+ except S3EncryptionClientError:
+ # Re-raise our own exceptions without wrapping
+ raise
+ except ClientError as e:
+ # Wrap S3 service errors (e.g., NoSuchKey) with context
+ raise S3EncryptionClientError(
+ f"Failed to retrieve and/or decrypt object: {str(e)}"
+ ) from e
+ except Exception as e:
+ # Wrap any unexpected errors during decryption
+ raise S3EncryptionClientError(
+ f"Failed to retrieve and/or decrypt object: {str(e)}"
+ ) from e
+ finally:
+ # Clean up thread-local storage;
+ # do not clean up the client as it is not thread local only
+ for attr in _GET_OBJECT_CLEANUP_ATTRS:
+ if hasattr(self._plugin._context, attr):
+ delattr(self._plugin._context, attr)
+
+ ##= specification/s3-encryption/client.md#optional-api-operations
+ ##= type=implementation
+ ##% CreateMultipartUpload MAY be implemented by the S3EC.
+ def create_multipart_upload(self, **kwargs):
+ """Initiate an encrypted multipart upload.
+
+ Obtains encryption materials, initializes the cipher, and calls
+ the underlying S3 CreateMultipartUpload. Encryption metadata is
+ set on the object at this point.
+
+ Args:
+ **kwargs: Arguments for S3 create_multipart_upload.
+ May include EncryptionContext.
+
+ Returns:
+ The response from S3 create_multipart_upload.
+ """
+ encryption_context = kwargs.pop("EncryptionContext", None)
+ _validate_encryption_context(encryption_context)
+
+ pipeline = MultipartUploadPipeline(
+ cmm=self.config.cmm,
+ encryption_algorithm=self.config.encryption_algorithm,
+ encryption_context=encryption_context or {},
+ )
+
+ # Merge encryption metadata into user-provided Metadata
+ user_metadata = dict(kwargs.get("Metadata", {}))
+ user_metadata.update(pipeline.metadata)
+ kwargs["Metadata"] = user_metadata
+
+ ##= specification/s3-encryption/client.md#optional-api-operations
+ ##= type=implementation
+ ##% If implemented, CreateMultipartUpload MUST initiate a multipart upload.
+ try:
+ response = self.wrapped_s3_client.create_multipart_upload(**kwargs)
+ except Exception as e:
+ raise S3EncryptionClientError(f"Failed to create multipart upload: {e}") from e
+
+ upload_id = response["UploadId"]
+ with self._multipart_lock:
+ self._multipart_uploads[upload_id] = pipeline
+ return response
+
+ ##= specification/s3-encryption/client.md#optional-api-operations
+ ##= type=implementation
+ ##% UploadPart MAY be implemented by the S3EC.
+ ##= specification/s3-encryption/client.md#optional-api-operations
+ ##= type=implementation
+ ##% UploadPart MUST encrypt each part.
+ ##= specification/s3-encryption/client.md#optional-api-operations
+ ##= type=implementation
+ ##% Each part MUST be encrypted in sequence.
+ ##= specification/s3-encryption/client.md#optional-api-operations
+ ##= type=implementation
+ ##% Each part MUST be encrypted using the same cipher instance for each part.
+ def upload_part(self, **kwargs):
+ """Encrypt and upload a single part of a multipart upload.
+
+ Parts must be uploaded in sequential order (1, 2, 3, ...).
+ The caller MUST set ``IsLastPart=True`` on the final part so the
+ GCM authentication tag is appended to the ciphertext.
+
+ Args:
+ **kwargs: Arguments for S3 upload_part. Must include UploadId,
+ PartNumber, and Body. Set IsLastPart=True on the
+ final part.
+
+ Returns:
+ The response from S3 upload_part (includes ETag).
+ """
+ upload_id = kwargs.get("UploadId")
+ with self._multipart_lock:
+ pipeline = self._multipart_uploads.get(upload_id)
+ if pipeline is None:
+ raise S3EncryptionClientError(
+ f"No multipart upload found for UploadId: {upload_id}. "
+ "Call create_multipart_upload first."
+ )
+
+ part_number = kwargs["PartNumber"]
+ is_last = kwargs.pop("IsLastPart", False)
+ body = kwargs.get("Body", b"")
+ if isinstance(body, str):
+ body = body.encode("utf-8")
+ elif hasattr(body, "read"):
+ body = body.read()
+
+ try:
+ ciphertext = pipeline.encrypt_part(part_number, body, is_last=is_last)
+ except S3EncryptionClientError:
+ raise
+ except Exception as e:
+ raise S3EncryptionClientError(f"Failed to encrypt part {part_number}: {e}") from e
+
+ kwargs["Body"] = ciphertext
+ return self.wrapped_s3_client.upload_part(**kwargs)
+
+ ##= specification/s3-encryption/client.md#optional-api-operations
+ ##= type=implementation
+ ##% CompleteMultipartUpload MAY be implemented by the S3EC.
+ ##% CompleteMultipartUpload MUST complete the multipart upload.
+ def complete_multipart_upload(self, **kwargs):
+ """Complete the multipart upload.
+
+ The final part must have been uploaded with ``IsLastPart=True``
+ before calling this method.
+
+ Args:
+ **kwargs: Arguments for S3 complete_multipart_upload.
+ MultipartUpload.Parts must include PartNumber and ETag
+ for each part.
+
+ Returns:
+ The response from S3 complete_multipart_upload.
+ """
+ upload_id = kwargs.get("UploadId")
+ with self._multipart_lock:
+ pipeline = self._multipart_uploads.get(upload_id)
+ if pipeline is None:
+ raise S3EncryptionClientError(f"No multipart upload found for UploadId: {upload_id}.")
+
+ if not pipeline.has_final_part_been_seen:
+ raise S3EncryptionClientError(
+ "Cannot complete multipart upload: the final part has not been uploaded. "
+ "Set IsLastPart=True on the last upload_part call."
+ )
+
+ try:
+ response = self.wrapped_s3_client.complete_multipart_upload(**kwargs)
+ except S3EncryptionClientError:
+ raise
+ except Exception as e:
+ raise S3EncryptionClientError(f"Failed to complete multipart upload: {e}") from e
+ else:
+ with self._multipart_lock:
+ self._multipart_uploads.pop(upload_id, None)
+ return response
+
+ ##= specification/s3-encryption/client.md#optional-api-operations
+ ##= type=implementation
+ ##% AbortMultipartUpload MAY be implemented by the S3EC.
+ ##% AbortMultipartUpload MUST abort the multipart upload.
+ def abort_multipart_upload(self, **kwargs):
+ """Abort a multipart upload and clean up cipher state.
+
+ Args:
+ **kwargs: Arguments for S3 abort_multipart_upload.
+
+ Returns:
+ The response from S3 abort_multipart_upload.
+ """
+ upload_id = kwargs.get("UploadId")
+ with self._multipart_lock:
+ self._multipart_uploads.pop(upload_id, None)
+ return self.wrapped_s3_client.abort_multipart_upload(**kwargs)
+
+ def upload_file(
+ self, filename, bucket, key, multipart_threshold=None, multipart_chunksize=None, **kwargs
+ ):
+ """Encrypt and upload a file to S3.
+
+ If the file is smaller than the threshold, uses put_object.
+ Otherwise, performs an encrypted multipart upload.
+
+ Args:
+ filename: Path to the file to upload.
+ bucket: Target S3 bucket.
+ key: Target S3 object key.
+ multipart_threshold: File size threshold for multipart (default 8MB).
+ multipart_chunksize: Size of each part (default 8MB).
+ **kwargs: Additional arguments (e.g. EncryptionContext, Metadata).
+ """
+ threshold = (
+ _DEFAULT_MULTIPART_THRESHOLD if multipart_threshold is None else multipart_threshold
+ )
+ chunksize = (
+ _DEFAULT_MULTIPART_CHUNKSIZE if multipart_chunksize is None else multipart_chunksize
+ )
+ if threshold <= 0:
+ raise S3EncryptionClientError("multipart_threshold must be a positive integer.")
+ if chunksize <= 0:
+ raise S3EncryptionClientError("multipart_chunksize must be a positive integer.")
+ if chunksize < _MIN_MULTIPART_PART_SIZE:
+ raise S3EncryptionClientError(
+ f"multipart_chunksize must be at least {_MIN_MULTIPART_PART_SIZE} bytes (5 MB). "
+ f"S3 requires all non-final parts to be at least 5 MB."
+ )
+ file_size = os.path.getsize(filename)
+
+ if file_size < threshold:
+ with open(filename, "rb") as f:
+ kwargs["Bucket"] = bucket
+ kwargs["Key"] = key
+ kwargs["Body"] = f.read()
+ return self.put_object(**kwargs)
+
+ return self._multipart_upload_from_readable(
+ open(filename, "rb"), bucket, key, chunksize, owns_readable=True, **kwargs
+ )
+
+ def upload_fileobj(self, fileobj, bucket, key, multipart_chunksize=None, **kwargs):
+ """Encrypt and upload a file-like object to S3 via multipart upload.
+
+ The caller retains ownership of fileobj — it will not be closed
+ by this method.
+
+ Args:
+ fileobj: A file-like object with a read() method.
+ bucket: Target S3 bucket.
+ key: Target S3 object key.
+ multipart_chunksize: Size of each part (default 8MB).
+ **kwargs: Additional arguments (e.g. EncryptionContext, Metadata).
+ """
+ chunksize = (
+ _DEFAULT_MULTIPART_CHUNKSIZE if multipart_chunksize is None else multipart_chunksize
+ )
+ if chunksize <= 0:
+ raise S3EncryptionClientError("multipart_chunksize must be a positive integer.")
+ if chunksize < _MIN_MULTIPART_PART_SIZE:
+ raise S3EncryptionClientError(
+ f"multipart_chunksize must be at least {_MIN_MULTIPART_PART_SIZE} bytes (5 MB). "
+ f"S3 requires all non-final parts to be at least 5 MB."
+ )
+ return self._multipart_upload_from_readable(
+ fileobj, bucket, key, chunksize, owns_readable=False, **kwargs
+ )
+
+ def _multipart_upload_from_readable(
+ self, readable, bucket, key, chunksize, *, owns_readable=False, **kwargs
+ ):
+ """Perform an encrypted multipart upload from a readable source.
+
+ Args:
+ readable: File-like object to read from.
+ bucket: Target S3 bucket.
+ key: Target S3 object key.
+ chunksize: Size of each part in bytes.
+ owns_readable: If True, close readable when done. If False,
+ the caller is responsible for closing it.
+ **kwargs: Additional S3 parameters forwarded to create_multipart_upload.
+ """
+ # EncryptionContext is consumed by our pipeline, not S3
+ create_kwargs = {"Bucket": bucket, "Key": key}
+ if "EncryptionContext" in kwargs:
+ create_kwargs["EncryptionContext"] = kwargs.pop("EncryptionContext")
+ if "Metadata" in kwargs:
+ create_kwargs["Metadata"] = kwargs.pop("Metadata")
+ # Forward remaining kwargs (ACL, ContentType, Tagging, etc.) to create_multipart_upload
+ create_kwargs.update(kwargs)
+
+ create_resp = self.create_multipart_upload(**create_kwargs)
+ upload_id = create_resp["UploadId"]
+
+ try:
+ parts = []
+ part_number = 0
+ # Read ahead so we can detect the last chunk
+ current_chunk = readable.read(chunksize)
+ while current_chunk:
+ next_chunk = readable.read(chunksize)
+ part_number += 1
+ is_last = not next_chunk
+ resp = self.upload_part(
+ Bucket=bucket,
+ Key=key,
+ UploadId=upload_id,
+ PartNumber=part_number,
+ Body=current_chunk,
+ IsLastPart=is_last,
+ )
+ parts.append({"PartNumber": part_number, "ETag": resp["ETag"]})
+ current_chunk = next_chunk
+
+ return self.complete_multipart_upload(
+ Bucket=bucket,
+ Key=key,
+ UploadId=upload_id,
+ MultipartUpload={"Parts": parts},
+ )
+ except Exception:
+ self.abort_multipart_upload(Bucket=bucket, Key=key, UploadId=upload_id)
+ raise
+ finally:
+ if owns_readable and hasattr(readable, "close"):
+ readable.close()
diff --git a/src/s3_encryption/_utils.py b/src/s3_encryption/_utils.py
new file mode 100644
index 00000000..7cab9e27
--- /dev/null
+++ b/src/s3_encryption/_utils.py
@@ -0,0 +1,24 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# 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.
+
+ This avoids the common pitfall where ``d.get(key, {})`` returns None
+ 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/buffered_decrypt.py b/src/s3_encryption/buffered_decrypt.py
new file mode 100644
index 00000000..6c305751
--- /dev/null
+++ b/src/s3_encryption/buffered_decrypt.py
@@ -0,0 +1,20 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""One Shot decryption into a buffer."""
+
+from io import BytesIO
+
+from botocore.response import StreamingBody
+
+from s3_encryption.decryptor import Decryptor
+
+
+def one_shot_decrypt(streaming_body: StreamingBody, decryptor: Decryptor):
+ """Decrypt a streaming object.
+
+ Args:
+ streaming_body (object): A streaming object.
+ decryptor (Decryptor): Decryptor object.
+ """
+ plaintext = decryptor.finalize(streaming_body.read())
+ return StreamingBody(BytesIO(plaintext), len(plaintext))
diff --git a/src/s3_encryption/decryptor.py b/src/s3_encryption/decryptor.py
new file mode 100644
index 00000000..f76c19dc
--- /dev/null
+++ b/src/s3_encryption/decryptor.py
@@ -0,0 +1,144 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Decryptor abstractions for S3 Encryption Client."""
+
+from abc import ABC, abstractmethod
+
+from attrs import define, field
+from cryptography.exceptions import InvalidTag
+
+from .exceptions import S3EncryptionClientError, S3EncryptionClientSecurityError
+
+
+class Decryptor(ABC):
+ """Abstract base class for content decryption.
+
+ Implementations own all cipher and padding state, presenting a uniform
+ streaming interface to the decrypting stream classes.
+ """
+
+ @property
+ @abstractmethod
+ def content_length(self) -> int:
+ """Total byte length of the encrypted content (ciphertext + any trailing tag)."""
+
+ @property
+ @abstractmethod
+ def amount_read(self) -> int:
+ """Number of ciphertext bytes consumed so far."""
+
+ @abstractmethod
+ def update(self, data: bytes) -> bytes:
+ """Process a chunk of ciphertext, returning any available plaintext."""
+
+ @abstractmethod
+ def finalize(self, data: bytes) -> bytes:
+ """Process the final chunk of ciphertext and finalize decryption."""
+
+
+@define
+class AesCbcDecryptor(Decryptor):
+ """AES-CBC decryptor that owns both the cipher and PKCS7 unpadder.
+
+ Args:
+ decryptor: A cryptography CBC cipher decryptor context.
+ unpadder: A cryptography PKCS7 unpadding context.
+ content_length: Total byte length of the CBC ciphertext.
+ """
+
+ _decryptor: object = field()
+ _unpadder: object = field()
+ _content_length: int = field()
+ _amount_read: int = field(init=False, default=0)
+
+ @property
+ def content_length(self) -> int: # noqa: D102
+ return self._content_length
+
+ @property
+ def amount_read(self) -> int: # noqa: D102
+ return self._amount_read
+
+ def update(self, data: bytes) -> bytes:
+ """Decrypt a chunk and unpad incrementally."""
+ self._amount_read += len(data)
+ plaintext = self._decryptor.update(data)
+ return self._unpadder.update(plaintext)
+
+ def finalize(self, data: bytes) -> bytes:
+ """Finalize CBC decryption and flush the unpadder."""
+ try:
+ self._amount_read += len(data)
+ plaintext = self._decryptor.update(data) if data else b""
+ plaintext += self._decryptor.finalize()
+ return self._unpadder.update(plaintext) + self._unpadder.finalize()
+ except Exception:
+ # Use a fixed message for all CBC failures to prevent padding oracle attacks.
+ # Different failure modes (bad padding, truncated ciphertext, wrong key) MUST
+ # produce identical error responses so an attacker cannot distinguish them.
+ raise S3EncryptionClientSecurityError("Failed to decrypt CBC content.") from None
+
+
+@define
+class AesGcmDecryptor(Decryptor):
+ """AES-GCM decryptor that handles trailing auth tag verification.
+
+ Args:
+ decryptor: A cryptography GCM cipher decryptor context.
+ tag_length: Length of the GCM authentication tag in bytes.
+ content_length: Total byte length of the encrypted content (ciphertext + tag).
+ """
+
+ _decryptor: object = field()
+ _tag_length: int = field()
+ _content_length: int = field()
+ _amount_read: int = field(init=False, default=0)
+ _tail: bytes = field(init=False, default=b"")
+
+ @property
+ def content_length(self) -> int: # noqa: D102
+ return self._content_length
+
+ @property
+ def amount_read(self) -> int: # noqa: D102
+ return self._amount_read
+
+ @property
+ def tag_length(self) -> int:
+ """Length of the GCM authentication tag in bytes."""
+ return self._tag_length
+
+ def update(self, data: bytes) -> bytes:
+ """Decrypt a chunk, holding back the last tag_length bytes.
+
+ A rolling _tail buffer always retains the last tag_length bytes
+ so the GCM tag is never passed to the cipher's update().
+ """
+ self._amount_read += len(data)
+ buf = self._tail + data
+ if len(buf) <= self._tag_length:
+ self._tail = buf
+ return b""
+ self._tail = buf[-self._tag_length :]
+ return self._decryptor.update(buf[: -self._tag_length])
+
+ def finalize(self, data: bytes) -> bytes:
+ """Finalize decryption using the buffered tag."""
+ try:
+ self._amount_read += len(data)
+ buf = self._tail + data
+ if len(buf) < self._tag_length:
+ raise S3EncryptionClientError(
+ f"Incomplete GCM data: expected at least {self._tag_length} "
+ f"tag bytes, got {len(buf)} total remaining bytes."
+ )
+ tag = buf[-self._tag_length :]
+ ciphertext = buf[: -self._tag_length]
+ plaintext = self._decryptor.update(ciphertext) if ciphertext else b""
+ return plaintext + self._decryptor.finalize_with_tag(tag)
+ except S3EncryptionClientError:
+ raise
+ except InvalidTag as e:
+ raise S3EncryptionClientSecurityError(f"Failed to decrypt Object: {e}") from e
+ except Exception as e:
+ raise S3EncryptionClientError(f"Failed to decrypt Object: {e}") from e
diff --git a/src/s3_encryption/exceptions.py b/src/s3_encryption/exceptions.py
new file mode 100644
index 00000000..463a180d
--- /dev/null
+++ b/src/s3_encryption/exceptions.py
@@ -0,0 +1,28 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Exceptions for the S3 Encryption Client.
+
+This module contains custom exception classes used throughout the S3 Encryption Client.
+"""
+
+from botocore.exceptions import BotoCoreError
+
+
+class S3EncryptionClientError(BotoCoreError):
+ """Exception class for non-Security S3 Encryption Client errors."""
+
+ fmt = "{msg}"
+
+ def __init__(self, message="An unspecified S3 Encryption Client error occurred"):
+ """Initialize the exception with a message."""
+ super().__init__(msg=message)
+
+
+class S3EncryptionClientSecurityError(BotoCoreError):
+ """Security Exceptions for S3 Encryption Client errors."""
+
+ fmt = "{msg}"
+
+ def __init__(self, message="An unspecified S3 Encryption Client Security error occurred"):
+ """Initialize the exception with a message."""
+ super().__init__(msg=message)
diff --git a/src/s3_encryption/instruction_file.py b/src/s3_encryption/instruction_file.py
new file mode 100644
index 00000000..61df766f
--- /dev/null
+++ b/src/s3_encryption/instruction_file.py
@@ -0,0 +1,126 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Instruction file handling for S3 Encryption Client.
+
+This module provides utilities for fetching and parsing instruction files
+that contain encryption metadata for S3 objects.
+"""
+
+import json
+from typing import Any
+
+from botocore.exceptions import ClientError
+
+from ._utils import safe_get_dict
+from .exceptions import S3EncryptionClientError
+from .metadata import VALID_S3EC_METADATA_KEYS
+
+
+def parse_instruction_file(instruction_data: bytes, key: str) -> dict[str, Any]:
+ """Parse and validate instruction file data.
+
+ This function strictly validates that:
+ 1. The instruction file body is valid JSON
+ 2. The JSON contains only S3 Encryption Client metadata keys
+
+ Args:
+ instruction_data: Raw bytes from instruction file body
+ key: Instruction file key (for error messages)
+
+ Returns:
+ dict: Parsed JSON metadata from instruction file
+
+ Raises:
+ S3EncryptionClientError: If the instruction file is not valid JSON
+ or contains non-S3EC metadata keys
+ """
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ ##= type=implementation
+ ##% The content metadata stored in the Instruction File MUST be serialized to a JSON string.
+
+ # Validate JSON format
+ try:
+ metadata = json.loads(instruction_data)
+ except json.JSONDecodeError as e:
+ raise S3EncryptionClientError(f"Instruction file is not valid JSON: {key}") from e
+
+ # Validate that it's a dictionary
+ if not isinstance(metadata, dict):
+ raise S3EncryptionClientError(
+ f"Instruction file must contain a JSON object, got {type(metadata).__name__}: {key}"
+ )
+
+ # Validate that all keys are S3EC metadata keys
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+ ##= type=implementation
+ ##% The serialized JSON string MUST be the only contents of the Instruction File.
+ invalid_keys = set(metadata.keys()) - VALID_S3EC_METADATA_KEYS
+ if invalid_keys:
+ raise S3EncryptionClientError(
+ f"Instruction file contains invalid keys: {invalid_keys} in {key}"
+ )
+
+ return metadata
+
+
+def fetch_instruction_file(s3_client, bucket: str, key: str) -> dict[str, Any]:
+ """Fetch and parse an instruction file from S3.
+
+ This function:
+ 1. Fetches the instruction file in plaintext mode
+ 2. Returns the parsed metadata from the response Metadata field
+
+ S3EncryptionClientPlugin's event handler (on_get_object_after_call) handles:
+ - Parsing and validating the instruction file content
+ - Placing parsed metadata in response["Metadata"]
+
+ Args:
+ s3_client: Boto3 S3 client to use for fetching
+ bucket: S3 bucket name
+ key: S3 object key
+ Returns:
+ dict: Parsed JSON metadata from instruction file
+
+ Raises:
+ S3EncryptionClientError: If the instruction file is not valid JSON,
+ or contains non-S3EC metadata keys
+ """
+ # Set plaintext mode flag in thread-local context before calling get_object
+ # This will be checked by the event handler to skip decryption
+ if hasattr(s3_client, "_s3ec_plugin_context"):
+ s3_client._s3ec_plugin_context.instruction_file_mode = True
+ s3_client._s3ec_plugin_context.key = key
+ else:
+ raise S3EncryptionClientError(
+ f"Could not fetch instruction file without "
+ f"the S3 Encryption Client Plugin installed. Instruction key: {key}"
+ )
+
+ try:
+ response = s3_client.get_object(Bucket=bucket, Key=key)
+ except ClientError as e:
+ raise S3EncryptionClientError(
+ f"Exception encountered while fetching Instruction File. "
+ f"Ensure the object you are attempting to decrypt has been encrypted using the S3 Encryption Client. "
+ f"Instruction key: {key}"
+ ) from e
+ finally:
+ # Clear the flags after the call
+ if hasattr(s3_client, "_s3ec_plugin_context"):
+ s3_client._s3ec_plugin_context.instruction_file_mode = False
+
+ # In plaintext mode, the event handler places parsed metadata in Metadata field
+ metadata = safe_get_dict(response, "Metadata")
+
+ # Verify metadata is not empty
+ if not metadata:
+ raise S3EncryptionClientError(f"Instruction file returned empty metadata: {key}")
+
+ # Verify metadata contains at least one S3EC key
+ has_s3ec_key = any(key in VALID_S3EC_METADATA_KEYS for key in metadata)
+ if not has_s3ec_key:
+ raise S3EncryptionClientError(
+ f"Instruction file metadata does not contain any S3EC keys: {key}"
+ )
+
+ return metadata
diff --git a/src/s3_encryption/instruction_file_config.py b/src/s3_encryption/instruction_file_config.py
new file mode 100644
index 00000000..73320533
--- /dev/null
+++ b/src/s3_encryption/instruction_file_config.py
@@ -0,0 +1,34 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Instruction file configuration for S3 Encryption Client.
+
+This module provides configuration for instruction file behavior
+during encryption and decryption operations.
+"""
+
+from attrs import define, field
+
+
+@define
+class InstructionFileConfig:
+ """Configuration for instruction file behavior in the S3 Encryption Client.
+
+ Controls whether the client will interact with instruction files
+ as part of GetObject, DeleteObject, and DeleteObjects operations.
+
+ Attributes:
+ disable_get_object: If True, the client will not attempt to fetch
+ instruction files during GetObject (decryption) and will raise
+ an error if the object's metadata implies an instruction file
+ is required. Defaults to False.
+ disable_delete_object: If True, the client will not attempt to
+ delete the associated instruction file during DeleteObject.
+ Defaults to False.
+ disable_delete_objects: If True, the client will not attempt to
+ delete the associated instruction files during DeleteObjects.
+ Defaults to False.
+ """
+
+ disable_get_object: bool = field(default=False)
+ disable_delete_object: bool = field(default=False)
+ disable_delete_objects: bool = field(default=False)
diff --git a/src/s3_encryption/key_derivation.py b/src/s3_encryption/key_derivation.py
new file mode 100644
index 00000000..8183f5f3
--- /dev/null
+++ b/src/s3_encryption/key_derivation.py
@@ -0,0 +1,163 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Key derivation for S3 Encryption Client key-committing algorithm suites.
+
+Implements HKDF-based key derivation as specified in:
+ specification/s3-encryption/key-derivation.md
+
+For ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY:
+ - Extract: HKDF-SHA512, salt = Message ID (28 bytes), IKM = plaintext data key
+ - Expand (DEK): info = suite_id_bytes + b"DERIVEKEY", output = 32 bytes
+ - Expand (Commit Key): info = suite_id_bytes + b"COMMITKEY", output = 28 bytes
+"""
+
+from __future__ import annotations
+
+import hmac
+from typing import TYPE_CHECKING
+
+from cryptography.hazmat.primitives.hashes import SHA512
+from cryptography.hazmat.primitives.kdf.hkdf import HKDFExpand
+
+from .exceptions import S3EncryptionClientError, S3EncryptionClientSecurityError
+
+if TYPE_CHECKING:
+ from .materials.materials import AlgorithmSuite
+
+# Map of supported KDF hash algorithm names to cryptography hash classes.
+_HASH_ALGORITHMS = {
+ "sha512": SHA512,
+}
+
+
+def _hkdf_extract(salt: bytes, ikm: bytes, hash_algorithm: str) -> bytes:
+ """HKDF extract step using HMAC.
+
+ Args:
+ salt: The salt value (Message ID).
+ ikm: Input keying material (plaintext data key).
+ hash_algorithm: Hash algorithm name (e.g. "sha512").
+
+ Returns:
+ The pseudorandom key (PRK).
+ """
+ return hmac.new(salt, ikm, hash_algorithm).digest()
+
+
+def _hkdf_expand(prk: bytes, info: bytes, length: int, hash_algorithm: str) -> bytes:
+ """HKDF expand step.
+
+ Args:
+ prk: Pseudorandom key from extract step.
+ info: Context/application-specific info string.
+ length: Desired output length in bytes.
+ hash_algorithm: Hash algorithm name (e.g. "sha512").
+
+ Returns:
+ Output keying material of the requested length.
+
+ Raises:
+ S3EncryptionClientError: If the hash algorithm is not supported.
+ """
+ hash_cls = _HASH_ALGORITHMS.get(hash_algorithm)
+ if hash_cls is None:
+ raise S3EncryptionClientError(f"Unsupported KDF hash algorithm: {hash_algorithm}")
+ hkdf = HKDFExpand(algorithm=hash_cls(), length=length, info=info)
+ return hkdf.derive(prk)
+
+
+def derive_keys(
+ plaintext_data_key: bytes,
+ message_id: bytes,
+ algorithm_suite: AlgorithmSuite,
+) -> tuple[bytes, bytes]:
+ """Derive the encryption key and commitment key from a plaintext data key.
+
+ Uses HKDF with SHA-512 as specified in the S3EC key derivation spec.
+
+ Args:
+ plaintext_data_key: The plaintext data key from the keyring.
+ message_id: The generated Message ID used as the HKDF salt.
+ algorithm_suite: The algorithm suite whose parameters drive key lengths
+ and info strings.
+
+ Returns:
+ A tuple of (derived_encryption_key, commit_key).
+ """
+ suite_id = algorithm_suite.suite_id_bytes
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The length of the output keying material MUST equal the encryption key length specified by the algorithm suite encryption settings.
+ enc_key_len = algorithm_suite.data_key_length_bytes
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The length of the output keying material MUST equal the commit key length specified by the supported algorithm suites.
+ commit_key_len = algorithm_suite.commitment_length_bytes
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The hash function MUST be specified by the algorithm suite commitment settings.
+ hash_alg = algorithm_suite.kdf_hash_algorithm
+
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The length of the input keying material MUST equal the key derivation input length specified by the algorithm suite commit key derivation setting.
+ if len(plaintext_data_key) != enc_key_len:
+ raise S3EncryptionClientError(
+ f"Plaintext data key length ({len(plaintext_data_key)}) does not match "
+ f"the key derivation input length ({enc_key_len}) specified by the algorithm suite."
+ )
+
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The input keying material MUST be the plaintext data key (PDK) generated by the key provider.
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The salt MUST be the Message ID with the length defined in the algorithm suite.
+ prk = _hkdf_extract(salt=message_id, ikm=plaintext_data_key, hash_algorithm=hash_alg)
+
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The DEK input pseudorandom key MUST be the output from the extract step.
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The input info MUST be a concatenation of the algorithm suite ID as bytes
+ ##% followed by the string DERIVEKEY as UTF8 encoded bytes.
+ derived_encryption_key = _hkdf_expand(
+ prk, info=suite_id + b"DERIVEKEY", length=enc_key_len, hash_algorithm=hash_alg
+ )
+
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The CK input pseudorandom key MUST be the output from the extract step.
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% - The input info MUST be a concatenation of the algorithm suite ID as bytes
+ ##% followed by the string COMMITKEY as UTF8 encoded bytes.
+ commit_key = _hkdf_expand(
+ prk, info=suite_id + b"COMMITKEY", length=commit_key_len, hash_algorithm=hash_alg
+ )
+
+ return derived_encryption_key, commit_key
+
+
+def verify_commitment(stored_commitment: bytes, derived_commitment: bytes) -> None:
+ """Verify key commitment in constant time.
+
+ Args:
+ stored_commitment: The commitment value from the object metadata.
+ derived_commitment: The commitment value derived from the data key.
+
+ Raises:
+ S3EncryptionClientSecurityError: If the commitment values do not match.
+ """
+ ##= specification/s3-encryption/decryption.md#decrypting-with-commitment
+ ##= type=implementation
+ ##% When using an algorithm suite which supports key commitment, the verification of the derived key commitment value MUST be done in constant time.
+ ##= specification/s3-encryption/decryption.md#decrypting-with-commitment
+ ##= type=implementation
+ ##% When using an algorithm suite which supports key commitment, the client MUST throw an exception when the derived key commitment value
+ ##% and stored key commitment value do not match.
+ if not hmac.compare_digest(stored_commitment, derived_commitment):
+ raise S3EncryptionClientSecurityError(
+ "Key commitment verification failed: stored commitment does not match derived commitment."
+ )
diff --git a/src/s3_encryption/materials/__init__.py b/src/s3_encryption/materials/__init__.py
new file mode 100644
index 00000000..c5cc7d6d
--- /dev/null
+++ b/src/s3_encryption/materials/__init__.py
@@ -0,0 +1,24 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Materials package for S3 Encryption Client.
+
+This package contains classes and interfaces for cryptographic materials
+management, including keyrings, crypto materials managers, and encrypted data keys.
+"""
+
+from .crypto_materials_manager import AbstractCryptoMaterialsManager, DefaultCryptoMaterialsManager
+from .encrypted_data_key import EncryptedDataKey
+from .keyring import AbstractKeyring
+from .kms_keyring import KmsKeyring
+from .materials import AlgorithmSuite, CommitmentPolicy, EncryptionMaterials
+
+__all__ = [
+ "AbstractKeyring",
+ "KmsKeyring",
+ "AbstractCryptoMaterialsManager",
+ "DefaultCryptoMaterialsManager",
+ "EncryptedDataKey",
+ "AlgorithmSuite",
+ "CommitmentPolicy",
+ "EncryptionMaterials",
+]
diff --git a/src/s3_encryption/materials/crypto_materials_manager.py b/src/s3_encryption/materials/crypto_materials_manager.py
new file mode 100644
index 00000000..6a7dd3e8
--- /dev/null
+++ b/src/s3_encryption/materials/crypto_materials_manager.py
@@ -0,0 +1,102 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Crypto materials manager module for S3 Encryption Client.
+
+This module provides interfaces and implementations for crypto materials managers,
+which are responsible for coordinating the generation and use of cryptographic materials.
+"""
+
+import abc
+
+from attrs import define
+
+from .._utils import safe_get_dict
+from .keyring import AbstractKeyring
+from .materials import DecryptionMaterials, EncryptionMaterials
+
+
+# API Stub for CMM
+class AbstractCryptoMaterialsManager(abc.ABC):
+ """Abstract base class for crypto materials managers.
+
+ A crypto materials manager is responsible for generating encryption materials
+ and processing decryption materials using a keyring.
+ """
+
+ @abc.abstractmethod
+ def get_encryption_materials(self, enc_mats_request):
+ """Get encryption materials from the keyring.
+
+ Args:
+ enc_mats_request (Dict[str, Any] or EncryptionMaterials): Request containing encryption
+ parameters
+
+ Returns:
+ EncryptionMaterials: The encryption materials
+ """
+ pass
+
+ @abc.abstractmethod
+ def decrypt_materials(self, dec_mats_request):
+ """Decrypt materials using the keyring.
+
+ Args:
+ dec_mats_request (Dict[str, Any] or DecryptionMaterials): Request containing decryption
+ parameters
+
+ Returns:
+ DecryptionMaterials: The decryption materials
+ """
+ pass
+
+
+@define
+class DefaultCryptoMaterialsManager(AbstractCryptoMaterialsManager):
+ """Default implementation of the crypto materials manager.
+
+ This implementation delegates encryption and decryption operations to a single keyring.
+
+ Attributes:
+ keyring (AbstractKeyring): The keyring to use for cryptographic operations
+ """
+
+ keyring: AbstractKeyring
+
+ def get_encryption_materials(self, enc_mats_request):
+ """Get encryption materials from the keyring.
+
+ Args:
+ enc_mats_request (Dict[str, Any] or EncryptionMaterials): Request containing encryption
+ parameters
+
+ Returns:
+ EncryptionMaterials: The encryption materials
+ """
+ # Convert dictionary to EncryptionMaterials if needed
+ if isinstance(enc_mats_request, dict):
+ materials = EncryptionMaterials(
+ encryption_context=safe_get_dict(enc_mats_request, "encryption_context")
+ )
+ else:
+ materials = enc_mats_request
+
+ return self.keyring.on_encrypt(materials)
+
+ def decrypt_materials(self, dec_mats_request):
+ """Decrypt materials using the keyring.
+
+ Args:
+ dec_mats_request (Dict[str, Any] or DecryptionMaterials): Request containing decryption
+ parameters
+
+ Returns:
+ DecryptionMaterials: The decryption materials
+ """
+ # Convert dictionary to DecryptionMaterials if needed
+ if isinstance(dec_mats_request, dict):
+ materials = DecryptionMaterials.from_dict(dec_mats_request)
+ else:
+ materials = dec_mats_request
+
+ encrypted_data_keys = materials.encrypted_data_keys
+ return self.keyring.on_decrypt(materials, encrypted_data_keys)
diff --git a/src/s3_encryption/materials/encrypted_data_key.py b/src/s3_encryption/materials/encrypted_data_key.py
new file mode 100644
index 00000000..b2c2359a
--- /dev/null
+++ b/src/s3_encryption/materials/encrypted_data_key.py
@@ -0,0 +1,27 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Encrypted data key module for S3 Encryption Client.
+
+This module provides the EncryptedDataKey class which represents an encrypted
+data key used in the S3 encryption process.
+"""
+
+from attrs import define
+
+
+@define
+class EncryptedDataKey:
+ """Class representing an encrypted data key.
+
+ An encrypted data key contains information about the key provider
+ and the encrypted data key itself.
+
+ Attributes:
+ key_provider_info (str): Information about the key provider
+ key_provider_id (bytes): Identifier for the key provider
+ encrypted_data_key (bytes): The encrypted data key
+ """
+
+ key_provider_info: str
+ key_provider_id: bytes
+ encrypted_data_key: bytes
diff --git a/src/s3_encryption/materials/keyring.py b/src/s3_encryption/materials/keyring.py
new file mode 100644
index 00000000..d0ecd96b
--- /dev/null
+++ b/src/s3_encryption/materials/keyring.py
@@ -0,0 +1,167 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Keyring module for S3 Encryption Client.
+
+This module provides interfaces and implementations for keyrings, which are
+responsible for encrypting and decrypting data keys used in the S3 encryption process.
+"""
+
+import abc
+
+from attrs import define
+
+from ..exceptions import S3EncryptionClientError
+from .materials import DecryptionMaterials, EncryptionMaterials
+
+
+##= specification/s3-encryption/materials/keyrings.md#interface
+##= type=implication
+##% The Keyring interface and its operations SHOULD adhere to the naming conventions of the
+##% implementation language.
+##= specification/s3-encryption/materials/keyrings.md#supported-keyrings
+##= type=implication
+##% Note: A user MAY create their own custom keyring(s).
+@define
+class AbstractKeyring(abc.ABC):
+ """Abstract base class for keyrings.
+
+ A keyring is responsible for encrypting and decrypting data keys.
+ Concrete implementations handle specific key providers like KMS.
+ """
+
+ ##= specification/s3-encryption/materials/keyrings.md#interface
+ ##= type=implication
+ ##% The Keyring interface MUST include the OnEncrypt operation.
+ ##% The OnEncrypt operation MUST accept an instance of EncryptionMaterials as input.
+ ##% The OnEncrypt operation MUST return an instance of EncryptionMaterials as output.
+ @abc.abstractmethod
+ def on_encrypt(self, enc_materials) -> "EncryptionMaterials":
+ """Process encryption materials.
+
+ Args:
+ enc_materials (EncryptionMaterials or dict): Encryption materials to process
+
+ Returns:
+ EncryptionMaterials: The processed encryption materials
+ """
+ pass
+
+ ##= specification/s3-encryption/materials/keyrings.md#interface
+ ##= type=implication
+ ##% The Keyring interface MUST include the OnDecrypt operation.
+ ##% The OnDecrypt operation MUST accept an instance of DecryptionMaterials and a collection
+ ##% of EncryptedDataKey instances as input.
+ ##% The OnDecrypt operation MUST return an instance of DecryptionMaterials as output.
+ @abc.abstractmethod
+ def on_decrypt(self, dec_materials, encrypted_data_keys=None) -> "DecryptionMaterials":
+ """Decrypt one of the encrypted data keys and update dec_materials.
+
+ Args:
+ dec_materials (DecryptionMaterials): A DecryptionMaterials instance containing
+ decryption materials
+ encrypted_data_keys (List[EncryptedDataKey], optional): A list of encrypted data
+ keys to try.
+
+ Returns:
+ DecryptionMaterials: The updated dec_materials with the plaintext data key
+ """
+ pass
+
+
+##= specification/s3-encryption/materials/s3-keyring.md#overview
+##= type=implication
+##% The S3EC SHOULD implement an S3 Keyring to consolidate validation and other functionality
+##% common to all S3 Keyrings.
+##% If implemented, the S3 Keyring MUST implement the Keyring interface.
+@define
+class S3Keyring(AbstractKeyring):
+ """Abstract class for S3EC keyrings that provides common validation logic."""
+
+ ##= specification/s3-encryption/materials/s3-keyring.md#overview
+ ##= type=implication
+ ##% If implemented, the S3 Keyring MUST NOT be able to be instantiated as a Keyring instance.
+ @abc.abstractmethod
+ def on_encrypt(self, enc_materials):
+ """Validate encryption materials before encryption.
+
+ Args:
+ enc_materials (EncryptionMaterials or dict): Encryption materials
+
+ Returns:
+ EncryptionMaterials: The validated encryption materials
+ """
+ # Convert dict to EncryptionMaterials if needed
+ if isinstance(enc_materials, dict):
+ enc_materials = EncryptionMaterials.from_dict(enc_materials)
+
+ # Validate encryption materials
+ if not isinstance(enc_materials, EncryptionMaterials):
+ raise S3EncryptionClientError(
+ "Encryption materials must be an EncryptionMaterials instance or a dictionary"
+ )
+
+ # Ensure encryption_context is a dictionary
+ if not isinstance(enc_materials.encryption_context, dict):
+ raise S3EncryptionClientError("Encryption context must be a dictionary")
+
+ return enc_materials
+
+ ##= specification/s3-encryption/materials/s3-keyring.md#overview
+ ##= type=implication
+ ##% If implemented, the S3 Keyring MUST NOT be able to be instantiated as a Keyring instance.
+ @abc.abstractmethod
+ def on_decrypt(self, dec_materials, encrypted_data_keys=None):
+ """Validate decryption materials before decryption.
+
+ Args:
+ dec_materials (DecryptionMaterials): A DecryptionMaterials instance containing
+ decryption materials
+ encrypted_data_keys (List[EncryptedDataKey], optional): A list of encrypted data
+ keys to try.
+
+ Returns:
+ DecryptionMaterials: The validated decryption materials
+ """
+ # Validate decryption materials
+ if not isinstance(dec_materials, DecryptionMaterials):
+ raise S3EncryptionClientError(
+ "Decryption materials must be a DecryptionMaterials instance"
+ )
+
+ # Use encrypted_data_keys from parameters if provided, otherwise use from dec_materials
+ # TODO: This can probably be cleaned up, consult Java
+ edks = (
+ encrypted_data_keys
+ if encrypted_data_keys is not None
+ else dec_materials.encrypted_data_keys
+ )
+
+ if edks is None:
+ raise S3EncryptionClientError("No EncryptedDataKey provided on decrypt!")
+
+ ##= specification/s3-encryption/materials/s3-keyring.md#ondecrypt
+ ##= type=implication
+ ##% If the input DecryptionMaterials contains a Plaintext Data Key, the S3 Keyring MUST
+ ##% throw an exception.
+ if dec_materials.plaintext_data_key is not None:
+ raise S3EncryptionClientError(
+ "Decryption materials already contains a plaintext data key."
+ )
+
+ ##= specification/s3-encryption/materials/s3-keyring.md#ondecrypt
+ ##= type=implication
+ ##% If the input collection of EncryptedDataKey instances contains any number of EDKs
+ ##% other than 1, the S3 Keyring MUST throw an exception.
+ if len(edks) != 1:
+ raise S3EncryptionClientError(
+ f"Only one encrypted data key is supported, found: {len(edks)}"
+ )
+
+ # Ensure encryption contexts are dictionaries
+ if not isinstance(dec_materials.encryption_context_from_request, dict):
+ raise S3EncryptionClientError("Encryption context from request must be a dictionary")
+
+ if not isinstance(dec_materials.encryption_context_stored, dict):
+ raise S3EncryptionClientError("Stored encryption context must be a dictionary")
+
+ return dec_materials
diff --git a/src/s3_encryption/materials/kms_keyring.py b/src/s3_encryption/materials/kms_keyring.py
new file mode 100644
index 00000000..083cca63
--- /dev/null
+++ b/src/s3_encryption/materials/kms_keyring.py
@@ -0,0 +1,273 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""KMS keyring module for S3 Encryption Client.
+
+This module provides a KMS-based keyring implementation that uses AWS KMS
+to generate and decrypt data keys for S3 object encryption.
+"""
+
+from attrs import define, field
+from botocore import client
+
+from ..exceptions import S3EncryptionClientError
+from ..materials.materials import AlgorithmSuite
+from .encrypted_data_key import EncryptedDataKey
+from .keyring import S3Keyring
+
+KMS_CONTEXT_DEFAULT_KEY = "aws:x-amz-cek-alg"
+KMS_V1_DEFAULT_KEY = "kms_cmk_id"
+
+
+##= specification/s3-encryption/materials/s3-kms-keyring.md#interface
+##= type=implication
+##% The KmsKeyring MUST implement the [Keyring interface](keyrings.md#interface) and include
+##% the behavior described in the [S3 Keyring](s3-keyring.md).
+@define
+class KmsKeyring(S3Keyring):
+ """KMS implementation of the S3 keyring.
+
+ This keyring uses AWS KMS to generate and decrypt data keys.
+
+ Attributes:
+ kms_client (client.BaseClient): The boto3 KMS client
+ kms_key_id (str): The KMS key ID to use
+ enable_legacy_wrapping_algorithms (bool): Whether to enable legacy wrapping algorithms
+ """
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#initialization
+ ##= type=implementation
+ ##% On initialization, the caller MAY provide an AWS KMS SDK client instance.
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#initialization
+ ##= type=implication
+ ##% If the caller does not provide an AWS KMS SDK client instance or provides a null value,
+ ##% the KmsKeyring MUST create a default KMS client instance.
+ kms_client: client.BaseClient = field()
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#initialization
+ ##= type=implementation
+ ##% On initialization, the caller MUST provide an AWS KMS key identifier.
+ kms_key_id: str = field()
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#supported-wrapping-algorithm-modes
+ ##= type=implementation
+ ##% 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.
+
+ Args:
+ enc_materials (EncryptionMaterials or dict): Encryption materials to process
+
+ Returns:
+ EncryptionMaterials: The processed encryption materials with KMS-generated keys
+ """
+ try:
+ enc_materials = super().on_encrypt(enc_materials)
+
+ encryption_context = enc_materials.encryption_context
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#supported-wrapping-algorithm-modes
+ ##= type=implementation
+ ##% The KmsKeyring MUST support encryption using Kms+Context mode.
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#supported-wrapping-algorithm-modes
+ ##= type=implementation
+ ##% The Kms+Context mode MUST be enabled as a fully-supported (non-legacy) wrapping
+ ##% algorithm.
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#supported-wrapping-algorithm-modes
+ ##= type=implication
+ ##% The KmsKeyring MUST NOT support encryption using KmsV1 mode.
+ # For committing algorithm suites (V3), the encryption context algorithm
+ # value is the algorithm suite ID as a string ("115"), not the cipher name.
+ # For non-committing suites (V2), use the cipher name ("AES/GCM/NoPadding").
+ if (
+ enc_materials.encryption_algorithm
+ == AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ ):
+ encryption_context["aws:x-amz-cek-alg"] = str(
+ enc_materials.encryption_algorithm.suite_id
+ )
+ else:
+ encryption_context["aws:x-amz-cek-alg"] = (
+ enc_materials.encryption_algorithm.cipher_name
+ )
+
+ # Python implementation uses KMS GenerateDataKey instead of the spec's
+ # EncryptDataKey pattern
+ # The spec is wrong and needs to be updated.
+ response = self.kms_client.generate_data_key(
+ KeyId=self.kms_key_id, KeySpec="AES_256", EncryptionContext=encryption_context
+ )
+ # Create an EncryptedDataKey instance
+ encrypted_data_key = EncryptedDataKey(
+ key_provider_id=b"S3Keyring",
+ key_provider_info="kms+context",
+ encrypted_data_key=response["CiphertextBlob"],
+ )
+ enc_materials.encrypted_data_key = encrypted_data_key
+ enc_materials.plaintext_data_key = response["Plaintext"]
+ return enc_materials
+ except Exception:
+ # If KMS call fails, propagate the exception
+ raise
+
+ def on_decrypt(self, dec_materials, encrypted_data_keys=None):
+ """Decrypt one of the encrypted data keys and update dec_materials.
+
+ Args:
+ dec_materials (DecryptionMaterials): A DecryptionMaterials instance containing
+ decryption materials
+ encrypted_data_keys (List[EncryptedDataKey], optional): A list of encrypted data
+ keys to try.
+
+ Returns:
+ DecryptionMaterials: The updated dec_materials with the plaintext data key
+ """
+ try:
+ ##= specification/s3-encryption/materials/s3-keyring.md#ondecrypt
+ ##= type=implication
+ ##% The OnDecrypt operation is responsible for ensuring that the DecryptionMaterials
+ ##% contain valid plaintext and encrypted data keys.
+ # Call parent class validation
+ dec_materials = super().on_decrypt(dec_materials, encrypted_data_keys)
+
+ # Use encrypted_data_keys from parameters if provided, otherwise use from dec_materials
+ edks = (
+ encrypted_data_keys
+ if encrypted_data_keys is not None
+ else dec_materials.encrypted_data_keys
+ )
+
+ # The parent class validation ensures there is exactly one EDK
+ edk = edks[0]
+ edk_bytes = edk.encrypted_data_key
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#supported-wrapping-algorithm-modes
+ ##= type=implementation
+ ##% The KmsKeyring MUST support decryption using Kms+Context mode.
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#decryptdatakey
+ ##= type=implementation
+ ##% The KmsKeyring MUST determine whether to decrypt using KmsV1 mode or
+ ##% Kms+Context mode.
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#decryptdatakey
+ ##= type=implementation
+ ##% If the Key Provider Info of the Encrypted Data Key is "kms+context", the
+ ##% KmsKeyring MUST attempt to decrypt using Kms+Context mode.
+ if edk.key_provider_info == "kms+context":
+ encryption_context_from_request = dec_materials.encryption_context_from_request
+ encryption_context_stored = dec_materials.encryption_context_stored
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ ##= type=implementation
+ ##% When decrypting using Kms+Context mode, the KmsKeyring MUST validate the
+ ##% provided (request) encryption context with the stored (materials) encryption
+ ##% context.
+ if KMS_CONTEXT_DEFAULT_KEY in encryption_context_from_request:
+ raise S3EncryptionClientError(
+ f"{KMS_CONTEXT_DEFAULT_KEY} is a reserved key for the S3 encryption client"
+ )
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ ##= type=implementation
+ ##% The stored encryption context with the two reserved keys removed MUST match
+ ##% the provided encryption context.
+ encryption_context_stored_copy = encryption_context_stored.copy()
+ encryption_context_stored_copy.pop(KMS_V1_DEFAULT_KEY, None)
+ encryption_context_stored_copy.pop(KMS_CONTEXT_DEFAULT_KEY, None)
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ ##= type=implementation
+ ##% If the stored encryption context with the two reserved keys removed does not
+ ##% match the provided encryption context, the KmsKeyring MUST throw an exception.
+ if encryption_context_stored_copy != encryption_context_from_request:
+ # TODO: modeled error
+ raise S3EncryptionClientError(
+ "Provided encryption context does not match information retrieved from S3"
+ )
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#decryptdatakey
+ ##= type=implication
+ ##% If the Key Provider Info of the Encrypted Data Key is "kms", the KmsKeyring
+ ##% MUST attempt to decrypt using KmsV1 mode.
+ elif edk.key_provider_info == "kms":
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#supported-wrapping-algorithm-modes
+ ##= type=implementation
+ ##% The KmsKeyring MUST support decryption using KmsV1 mode.
+ if not self.enable_legacy_wrapping_algorithms:
+ raise S3EncryptionClientError(
+ f"Enable legacy wrapping algorithms to use legacy key wrapping "
+ f"algorithm: {edk.key_provider_info}"
+ )
+ # The KmsV1 wrapping algorithm does not support caller-provided
+ # encryption context. If the caller provided encryption context,
+ # the client MUST reject the request. This prevents a downgrade
+ # from kms+context to kms from bypassing context validation.
+ if dec_materials.encryption_context_from_request:
+ raise S3EncryptionClientError(
+ "Encryption context is not supported with the KmsV1 (kms) "
+ "wrapping algorithm. Use kms+context wrapping algorithm to "
+ "use encryption context."
+ )
+ else:
+ raise S3EncryptionClientError(
+ f"{edk.key_provider_info} is not a valid key wrapping algorithm!"
+ )
+
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kmsv1
+ ##= type=implementation
+ ##% To attempt to decrypt a particular [encrypted data key](../structures.md#
+ ##% encrypted-data-key), the KmsKeyring MUST call [AWS KMS Decrypt](https://
+ ##% docs.aws.amazon.com/kms/latest/APIReference/API_Decrypt.html) with the
+ ##% configured AWS KMS client.
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kmsv1
+ ##= type=implementation
+ ##% - `KeyId` MUST be the configured AWS KMS key identifier.
+ ##% - `CiphertextBlob` MUST be the [encrypted data key ciphertext](
+ ##% ../structures.md#ciphertext).
+ ##% - `EncryptionContext` MUST be the [encryption context](../structures.md#
+ ##% encryption-context) included in the input [decryption materials](
+ ##% ../structures.md#decryption-materials).
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ ##= type=implementation
+ ##% To attempt to decrypt a particular [encrypted data key](../structures.md#
+ ##% encrypted-data-key), the KmsKeyring MUST call [AWS KMS Decrypt](https://
+ ##% docs.aws.amazon.com/kms/latest/APIReference/API_Decrypt.html) with the
+ ##% configured AWS KMS client.
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ ##= type=implication
+ ##% - `KeyId` MUST be the configured AWS KMS key identifier.
+ ##% - `CiphertextBlob` MUST be the [encrypted data key ciphertext](
+ ##% ../structures.md#ciphertext).
+ ##% - `EncryptionContext` MUST be the [encryption context](../structures.md#
+ ##% encryption-context) included in the input [decryption materials](
+ ##% ../structures.md#decryption-materials).
+ response = self.kms_client.decrypt(
+ KeyId=self.kms_key_id,
+ CiphertextBlob=edk_bytes,
+ EncryptionContext=dec_materials.encryption_context_stored,
+ )
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ ##= type=implication
+ ##% The KmsKeyring MUST immediately return the plaintext as a collection of
+ ##% bytes.
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kmsv1
+ ##= type=implication
+ ##% The KmsKeyring MUST immediately return the plaintext as a collection of
+ ##% bytes.
+ dec_materials.plaintext_data_key = response["Plaintext"]
+ return dec_materials
+ except Exception:
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kmsv1
+ ##= type=implementation
+ ##% If the KmsKeyring fails to successfully decrypt the [encrypted data key](
+ ##% ../structures.md#encrypted-data-key), then it MUST throw an exception.
+ ##= specification/s3-encryption/materials/s3-kms-keyring.md#kms-context
+ ##= type=implementation
+ ##% If the KmsKeyring fails to successfully decrypt the [encrypted data key](
+ ##% ../structures.md#encrypted-data-key), then it MUST throw an exception.
+ raise
diff --git a/src/s3_encryption/materials/materials.py b/src/s3_encryption/materials/materials.py
new file mode 100644
index 00000000..4f91330f
--- /dev/null
+++ b/src/s3_encryption/materials/materials.py
@@ -0,0 +1,326 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Materials module for S3 Encryption Client.
+
+This module provides classes for encryption and decryption materials,
+which contain the cryptographic materials needed for S3 object encryption
+and decryption operations.
+"""
+
+from enum import Enum
+from typing import Any
+
+from attrs import define, field
+
+from .._utils import safe_get_dict
+from .encrypted_data_key import EncryptedDataKey
+
+
+class AlgorithmSuite(Enum):
+ """Algorithm suites supported by the S3 Encryption Client.
+
+ Each member consolidates all cryptographic parameters for a given suite,
+ modeled after the Java reference implementation. The tuple values are:
+
+ (id, is_legacy, data_key_algorithm, data_key_length_bits,
+ cipher_name, cipher_block_size_bits, cipher_iv_length_bits,
+ cipher_tag_length_bits, is_committing, commitment_length_bits,
+ commitment_nonce_length_bits, kdf_hash_algorithm, suite_id_bytes)
+ """
+
+ ALG_AES_256_CBC_IV16_NO_KDF = (
+ 0x0070, # id
+ True, # is_legacy
+ "AES", # data_key_algorithm
+ 256, # data_key_length_bits
+ "AES/CBC/PKCS5Padding", # cipher_name
+ 128, # cipher_block_size_bits
+ 128, # cipher_iv_length_bits (16 bytes)
+ 0, # cipher_tag_length_bits (CBC has no auth tag)
+ False, # is_committing
+ 0, # commitment_length_bits
+ 0, # commitment_nonce_length_bits
+ None, # kdf_hash_algorithm
+ b"", # suite_id_bytes
+ )
+
+ ALG_AES_256_GCM_IV12_TAG16_NO_KDF = (
+ 0x0072, # id
+ False, # is_legacy
+ "AES", # data_key_algorithm
+ 256, # data_key_length_bits
+ "AES/GCM/NoPadding", # cipher_name
+ 128, # cipher_block_size_bits
+ 96, # cipher_iv_length_bits (12 bytes)
+ 128, # cipher_tag_length_bits (16 bytes)
+ False, # is_committing
+ 0, # commitment_length_bits
+ 0, # commitment_nonce_length_bits
+ None, # kdf_hash_algorithm
+ b"", # suite_id_bytes
+ )
+
+ ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY = (
+ 0x0073, # id
+ False, # is_legacy
+ "AES", # data_key_algorithm
+ 256, # data_key_length_bits
+ "AES/GCM/HKDF/CommitKey", # cipher_name
+ 128, # cipher_block_size_bits
+ 96, # cipher_iv_length_bits (12 bytes)
+ 128, # cipher_tag_length_bits (16 bytes)
+ True, # is_committing
+ 224, # commitment_length_bits (28 bytes)
+ 224, # commitment_nonce_length_bits (28 bytes = message_id)
+ "sha512", # kdf_hash_algorithm
+ b"\x00\x73", # suite_id_bytes
+ )
+
+ def __init__(
+ self,
+ suite_id: int,
+ is_legacy: bool,
+ data_key_algorithm: str,
+ data_key_length_bits: int,
+ cipher_name: str,
+ cipher_block_size_bits: int,
+ cipher_iv_length_bits: int,
+ cipher_tag_length_bits: int,
+ is_committing: bool,
+ commitment_length_bits: int,
+ commitment_nonce_length_bits: int,
+ kdf_hash_algorithm: str | None,
+ suite_id_bytes: bytes,
+ ):
+ """Initialize algorithm suite parameters from the enum tuple."""
+ self._id = suite_id
+ self._is_legacy = is_legacy
+ self._data_key_algorithm = data_key_algorithm
+ self._data_key_length_bits = data_key_length_bits
+ self._cipher_name = cipher_name
+ self._cipher_block_size_bits = cipher_block_size_bits
+ self._cipher_iv_length_bits = cipher_iv_length_bits
+ self._cipher_tag_length_bits = cipher_tag_length_bits
+ self._is_committing = is_committing
+ self._commitment_length_bits = commitment_length_bits
+ self._commitment_nonce_length_bits = commitment_nonce_length_bits
+ self._kdf_hash_algorithm = kdf_hash_algorithm
+ self._suite_id_bytes = suite_id_bytes
+
+ # --- Convenience properties ---
+
+ @property
+ def suite_id(self) -> int:
+ """Numeric identifier for this algorithm suite."""
+ return self._id
+
+ @property
+ def is_legacy(self) -> bool:
+ """Return True if this algorithm suite is a legacy unauthenticated mode."""
+ return self._is_legacy
+
+ @property
+ def supports_key_commitment(self) -> bool:
+ """Return True if this algorithm suite supports key commitment."""
+ return self._is_committing
+
+ @property
+ def data_key_length_bytes(self) -> int:
+ """Data key length in bytes."""
+ return self._data_key_length_bits // 8
+
+ @property
+ def cipher_name(self) -> str:
+ """Cipher transformation string (e.g. 'AES/GCM/NoPadding')."""
+ return self._cipher_name
+
+ @property
+ def cipher_iv_length_bytes(self) -> int:
+ """Initialization vector length in bytes."""
+ return self._cipher_iv_length_bits // 8
+
+ @property
+ def commitment_length_bytes(self) -> int:
+ """Key commitment value length in bytes."""
+ return self._commitment_length_bits // 8
+
+ @property
+ def commitment_nonce_length_bytes(self) -> int:
+ """Length of the message ID / HKDF salt in bytes."""
+ return self._commitment_nonce_length_bits // 8
+
+ @property
+ def suite_id_bytes(self) -> bytes:
+ """Algorithm suite ID as raw bytes for use in HKDF info strings."""
+ return self._suite_id_bytes
+
+ @property
+ def kdf_hash_algorithm(self) -> str | None:
+ """Hash algorithm name for HKDF, usable with hmac (e.g. 'sha512')."""
+ return self._kdf_hash_algorithm
+
+ @property
+ def kc_gcm_iv(self) -> bytes:
+ """Fixed IV for key-committing GCM: all 0x01 bytes of cipher_iv_length."""
+ if not self._is_committing:
+ raise ValueError(f"{self.name} does not support key commitment")
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% The IV's total length MUST match the IV length defined by the algorithm suite.
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% When encrypting or decrypting with ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ ##% the IV used in the AES-GCM content encryption/decryption MUST consist entirely of bytes with the value 0x01.
+ return b"\x01" * self.cipher_iv_length_bytes
+
+ @property
+ def cipher_block_size_bits(self) -> int:
+ """Block size of the cipher in bits."""
+ return self._cipher_block_size_bits
+
+ @property
+ def cipher_block_size_bytes(self) -> int:
+ """Block size of the cipher in bytes."""
+ return self._cipher_block_size_bits // 8
+
+ @property
+ def cipher_tag_length_bits(self) -> int:
+ """Authentication tag length of the cipher in bits."""
+ return self._cipher_tag_length_bits
+
+ @property
+ def cipher_tag_length_bytes(self) -> int:
+ """Authentication tag length of the cipher in bytes."""
+ return self._cipher_tag_length_bits // 8
+
+
+class CommitmentPolicy(Enum):
+ """Commitment policies controlling key-commitment behavior."""
+
+ FORBID_ENCRYPT_ALLOW_DECRYPT = "ForbidEncryptAllowDecrypt"
+ REQUIRE_ENCRYPT_ALLOW_DECRYPT = "RequireEncryptAllowDecrypt"
+ REQUIRE_ENCRYPT_REQUIRE_DECRYPT = "RequireEncryptRequireDecrypt"
+
+
+@define
+class EncryptionMaterials:
+ """Class representing encryption materials for S3 encryption.
+
+ This class provides a structured way to handle encryption materials
+ with fields corresponding to the data needed for encryption operations.
+
+ Attributes:
+ encryption_context (Dict[str, str]): Context information for encryption
+ encrypted_data_key (Optional[EncryptedDataKey]): The encrypted data key
+ plaintext_data_key (Optional[bytes]): The plaintext data key
+ """
+
+ encryption_algorithm: AlgorithmSuite = field(
+ default=AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ )
+ encryption_context: dict[str, str] = field(factory=dict)
+ encrypted_data_key: EncryptedDataKey | None = field(default=None)
+ plaintext_data_key: bytes | None = field(default=None)
+
+ @classmethod
+ def from_dict(cls, materials_dict: dict[str, Any]) -> "EncryptionMaterials":
+ """Create an EncryptionMaterials instance from a dictionary.
+
+ Args:
+ materials_dict (Dict[str, Any]): Dictionary containing encryption materials
+
+ Returns:
+ EncryptionMaterials: A new instance with fields populated from the dictionary
+ """
+ return cls(
+ encryption_context=safe_get_dict(materials_dict, "encryption_context"),
+ encrypted_data_key=materials_dict.get("encrypted_data_key"),
+ plaintext_data_key=materials_dict.get("plaintext_data_key"),
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert the EncryptionMaterials instance to a dictionary.
+
+ Returns:
+ Dict[str, Any]: Dictionary containing encryption materials
+ """
+ result = {}
+
+ if self.encryption_context:
+ result["encryption_context"] = self.encryption_context
+
+ if self.encrypted_data_key is not None:
+ result["encrypted_data_key"] = self.encrypted_data_key
+
+ if self.plaintext_data_key is not None:
+ result["plaintext_data_key"] = self.plaintext_data_key
+
+ return result
+
+
+@define
+class DecryptionMaterials:
+ """Class representing decryption materials for S3 encryption.
+
+ This class provides a structured way to handle decryption materials
+ with fields corresponding to the data needed for decryption operations.
+
+ Attributes:
+ iv (Optional[bytes]): The initialization vector used for content encryption
+ encrypted_data_keys (List[EncryptedDataKey]): List of encrypted data keys to try
+ encryption_context_stored (Dict[str, str]): Encryption context stored with the object
+ encryption_context_from_request (Dict[str, str]): Encryption context provided in the request
+ plaintext_data_key (Optional[bytes]): The plaintext data key
+ """
+
+ iv: bytes | None = field(default=None)
+ encrypted_data_keys: list[EncryptedDataKey] = field(factory=list)
+ encryption_context_stored: dict[str, str] = field(factory=dict)
+ encryption_context_from_request: dict[str, str] = field(factory=dict)
+ plaintext_data_key: bytes | None = field(default=None)
+ algorithm_suite: AlgorithmSuite | None = field(default=None)
+
+ @classmethod
+ def from_dict(cls, materials_dict: dict[str, Any]) -> "DecryptionMaterials":
+ """Create a DecryptionMaterials instance from a dictionary.
+
+ Args:
+ materials_dict (Dict[str, Any]): Dictionary containing decryption materials
+
+ Returns:
+ DecryptionMaterials: A new instance with fields populated from the dictionary
+ """
+ return cls(
+ iv=materials_dict.get("iv"),
+ encrypted_data_keys=materials_dict.get("encrypted_data_keys", []),
+ encryption_context_stored=safe_get_dict(materials_dict, "encryption_context_stored"),
+ encryption_context_from_request=safe_get_dict(
+ materials_dict, "encryption_context_from_request"
+ ),
+ plaintext_data_key=materials_dict.get("plaintext_data_key"),
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert the DecryptionMaterials instance to a dictionary.
+
+ Returns:
+ Dict[str, Any]: Dictionary containing decryption materials
+ """
+ result = {}
+
+ if self.iv is not None:
+ result["iv"] = self.iv
+
+ if self.encrypted_data_keys:
+ result["encrypted_data_keys"] = self.encrypted_data_keys
+
+ if self.encryption_context_stored:
+ result["encryption_context_stored"] = self.encryption_context_stored
+
+ if self.encryption_context_from_request:
+ result["encryption_context_from_request"] = self.encryption_context_from_request
+
+ if self.plaintext_data_key is not None:
+ result["plaintext_data_key"] = self.plaintext_data_key
+
+ return result
diff --git a/src/s3_encryption/metadata.py b/src/s3_encryption/metadata.py
new file mode 100644
index 00000000..0b0fbce6
--- /dev/null
+++ b/src/s3_encryption/metadata.py
@@ -0,0 +1,293 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Metadata handling for S3 Encryption Client.
+
+This module provides classes and utilities for managing encryption metadata
+for S3 objects, including serialization and deserialization of metadata.
+"""
+
+import json
+from typing import Any
+
+from attrs import define, field
+
+
+@define
+class ObjectMetadata:
+ """Class representing metadata for encrypted S3 objects.
+
+ This class provides a structured way to handle encryption metadata
+ with fields corresponding to standard S3 encryption headers.
+
+ All fields are optional and correspond to the following S3 encryption headers:
+ - encrypted_data_key_v1: The encrypted data key (legacy format)
+ - encrypted_data_key_v2: The encrypted data key (current format)
+ - encrypted_data_key_algorithm: The algorithm used to encrypt the data key
+ (e.g. AES/GCM or kms+context)
+ - encrypted_data_key_context: The encryption context used for the data key
+ - content_iv: The initialization vector used for content encryption
+ - content_cipher: The cipher algorithm used for content encryption (e.g. AES/GCM/NoPadding)
+ - content_cipher_tag_length: The length of the authentication tag
+ - instruction_file: Marker for instruction files
+ """
+
+ # The encrypted data key (legacy format)
+ encrypted_data_key_v1: str | None = field(default=None)
+ # The encrypted data key (current format)
+ encrypted_data_key_v2: str | None = field(default=None)
+ # The algorithm used to encrypt the data key (e.g. AES/GCM or kms+context)
+ encrypted_data_key_algorithm: str | None = field(default=None)
+ # The encryption context used for the data key
+ encrypted_data_key_context: dict | None = field(default=None)
+ # The initialization vector used for content encryption
+ content_iv: str | None = field(default=None)
+ # The cipher algorithm used for content encryption (e.g. AES/GCM/NoPadding)
+ content_cipher: str | None = field(default=None)
+ # The length of the authentication tag
+ content_cipher_tag_length: str | None = field(default="128")
+ # Marker for instruction files
+ instruction_file: str | None = field(default=None)
+
+ # V3 format fields (compressed)
+ content_cipher_v3: str | None = field(default=None)
+ encrypted_data_key_v3: str | None = field(default=None)
+ mat_desc_v3: str | dict | None = field(default=None)
+ encryption_context_v3: str | dict | None = field(default=None)
+ encrypted_data_key_algorithm_v3: str | None = field(default=None)
+ key_commitment_v3: str | None = field(default=None)
+ message_id_v3: str | None = field(default=None)
+
+ # Constants for metadata keys
+ ENCRYPTED_DATA_KEY_V1 = "x-amz-key"
+ ENCRYPTED_DATA_KEY_V2 = "x-amz-key-v2"
+ ENCRYPTED_DATA_KEY_ALGORITHM = "x-amz-wrap-alg"
+ ENCRYPTED_DATA_KEY_CONTEXT = "x-amz-matdesc"
+ CONTENT_IV = "x-amz-iv"
+ CONTENT_CIPHER = "x-amz-cek-alg"
+ CONTENT_CIPHER_TAG_LENGTH = "x-amz-tag-len"
+ INSTRUCTION_FILE = "x-amz-crypto-instr-file"
+
+ # V3 format constants (compressed)
+ CONTENT_CIPHER_V3 = "x-amz-c"
+ ENCRYPTED_DATA_KEY_V3 = "x-amz-3"
+ MAT_DESC_V3 = "x-amz-m"
+ ENCRYPTION_CONTEXT_V3 = "x-amz-t"
+ ENCRYPTED_DATA_KEY_ALGORITHM_V3 = "x-amz-w"
+ KEY_COMMITMENT_V3 = "x-amz-d"
+ MESSAGE_ID_V3 = "x-amz-i"
+
+ @classmethod
+ def from_dict(cls, metadata_dict: dict[str, Any]) -> "ObjectMetadata":
+ """Create an ObjectMetadata instance from a dictionary.
+
+ Args:
+ metadata_dict (Dict[str, Any]): Dictionary containing metadata keys and values
+
+ Returns:
+ ObjectMetadata: A new instance with fields populated from the dictionary
+ """
+ # Parse the encryption context if present
+ encryption_context = None
+ if cls.ENCRYPTED_DATA_KEY_CONTEXT in metadata_dict:
+ context_str = metadata_dict.get(cls.ENCRYPTED_DATA_KEY_CONTEXT)
+ if context_str is not None:
+ encryption_context = json.loads(context_str)
+
+ return cls(
+ encrypted_data_key_v1=metadata_dict.get(cls.ENCRYPTED_DATA_KEY_V1),
+ encrypted_data_key_v2=metadata_dict.get(cls.ENCRYPTED_DATA_KEY_V2),
+ encrypted_data_key_algorithm=metadata_dict.get(cls.ENCRYPTED_DATA_KEY_ALGORITHM),
+ encrypted_data_key_context=encryption_context,
+ content_iv=metadata_dict.get(cls.CONTENT_IV),
+ content_cipher=metadata_dict.get(cls.CONTENT_CIPHER),
+ content_cipher_tag_length=metadata_dict.get(cls.CONTENT_CIPHER_TAG_LENGTH),
+ instruction_file=metadata_dict.get(cls.INSTRUCTION_FILE),
+ content_cipher_v3=metadata_dict.get(cls.CONTENT_CIPHER_V3),
+ encrypted_data_key_v3=metadata_dict.get(cls.ENCRYPTED_DATA_KEY_V3),
+ mat_desc_v3=metadata_dict.get(cls.MAT_DESC_V3),
+ encryption_context_v3=metadata_dict.get(cls.ENCRYPTION_CONTEXT_V3),
+ encrypted_data_key_algorithm_v3=metadata_dict.get(cls.ENCRYPTED_DATA_KEY_ALGORITHM_V3),
+ key_commitment_v3=metadata_dict.get(cls.KEY_COMMITMENT_V3),
+ message_id_v3=metadata_dict.get(cls.MESSAGE_ID_V3),
+ )
+
+ def to_dict(self) -> dict[str, str]:
+ """Convert the ObjectMetadata instance to a dictionary.
+
+ Returns:
+ Dict[str, str]: Dictionary containing non-None metadata values
+ """
+ result = {}
+
+ if self.encrypted_data_key_v1 is not None:
+ result[self.ENCRYPTED_DATA_KEY_V1] = self.encrypted_data_key_v1
+
+ if self.encrypted_data_key_v2 is not None:
+ result[self.ENCRYPTED_DATA_KEY_V2] = self.encrypted_data_key_v2
+
+ if self.encrypted_data_key_algorithm is not None:
+ result[self.ENCRYPTED_DATA_KEY_ALGORITHM] = self.encrypted_data_key_algorithm
+
+ if self.encrypted_data_key_context is not None:
+ result[self.ENCRYPTED_DATA_KEY_CONTEXT] = json.dumps(self.encrypted_data_key_context)
+
+ if self.content_iv is not None:
+ result[self.CONTENT_IV] = self.content_iv
+
+ if self.content_cipher is not None:
+ result[self.CONTENT_CIPHER] = self.content_cipher
+
+ if self.content_cipher_tag_length is not None and not self.is_v3_format():
+ result[self.CONTENT_CIPHER_TAG_LENGTH] = self.content_cipher_tag_length
+
+ if self.instruction_file is not None:
+ result[self.INSTRUCTION_FILE] = self.instruction_file
+
+ if self.content_cipher_v3 is not None:
+ result[self.CONTENT_CIPHER_V3] = self.content_cipher_v3
+
+ if self.encrypted_data_key_v3 is not None:
+ result[self.ENCRYPTED_DATA_KEY_V3] = self.encrypted_data_key_v3
+
+ if self.mat_desc_v3 is not None:
+ if isinstance(self.mat_desc_v3, dict):
+ result[self.MAT_DESC_V3] = json.dumps(self.mat_desc_v3)
+ else:
+ result[self.MAT_DESC_V3] = self.mat_desc_v3
+
+ if self.encryption_context_v3 is not None:
+ if isinstance(self.encryption_context_v3, dict):
+ result[self.ENCRYPTION_CONTEXT_V3] = json.dumps(self.encryption_context_v3)
+ else:
+ result[self.ENCRYPTION_CONTEXT_V3] = self.encryption_context_v3
+
+ if self.encrypted_data_key_algorithm_v3 is not None:
+ result[self.ENCRYPTED_DATA_KEY_ALGORITHM_V3] = self.encrypted_data_key_algorithm_v3
+
+ if self.key_commitment_v3 is not None:
+ result[self.KEY_COMMITMENT_V3] = self.key_commitment_v3
+
+ if self.message_id_v3 is not None:
+ result[self.MESSAGE_ID_V3] = self.message_id_v3
+
+ return result
+
+ def is_v1_format(self) -> bool:
+ """Check if metadata is in V1 format.
+
+ Returns:
+ bool: True if metadata contains V1 keys and excludes V2/V3 keys
+ """
+ return (
+ self.content_iv is not None
+ and self.encrypted_data_key_context is not None
+ and self.encrypted_data_key_v1 is not None
+ and self.encrypted_data_key_v2 is None
+ )
+
+ def is_v2_format(self) -> bool:
+ """Check if metadata is in V2 format.
+
+ Returns:
+ bool: True if metadata contains V2 keys and excludes V1/V3 keys
+ """
+ return (
+ self.content_cipher is not None
+ and self.content_iv is not None
+ and self.encrypted_data_key_algorithm is not None
+ and self.encrypted_data_key_v2 is not None
+ and self.encrypted_data_key_v1 is None
+ )
+
+ def is_v3_format(self) -> bool:
+ """Check if metadata is in V3 format.
+
+ Returns:
+ bool: True if metadata contains V3 keys and excludes V1/V2 keys
+ """
+ return (
+ self.content_cipher_v3 is not None
+ and self.encrypted_data_key_algorithm_v3 is not None
+ and self.key_commitment_v3 is not None
+ and self.message_id_v3 is not None
+ and self.encrypted_data_key_v3 is not None
+ and self.encrypted_data_key_v2 is None
+ and self.encrypted_data_key_v1 is None
+ )
+
+ def has_exclusive_key_collision(self) -> bool:
+ """Check if metadata has multiple exclusive version keys.
+
+ Returns:
+ bool: True if more than one version key (V1, V2, V3) is present
+ """
+ has_v1_key = self.encrypted_data_key_v1 is not None
+ has_v2_key = self.encrypted_data_key_v2 is not None
+ has_v3_key = self.encrypted_data_key_v3 is not None
+
+ exclusive_key_count = sum([has_v1_key, has_v2_key, has_v3_key])
+ return exclusive_key_count > 1
+
+ def is_v3_in_object_metadata(self) -> bool:
+ """Check if V3 content keys are in object metadata (without encrypted data key).
+
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ ##= type=implementation
+ ##% In the V3 message format, only the content metadata related to
+ ##% the encrypted data is stored in the Instruction File.
+ ##% In the V3 message format, the content metadata related to
+ ##% the encrypted content is stored in the Object Metadata.
+
+ Returns:
+ bool: True if V3 content keys present but no encrypted data key
+ """
+ return (
+ self.content_cipher_v3 is not None
+ and self.key_commitment_v3 is not None
+ and self.message_id_v3 is not None
+ and self.encrypted_data_key_v3 is None
+ )
+
+ ##= specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status
+ ##= type=implementation
+ ##% If the object matches none of the V1/V2/V3 formats,
+ ##% the S3EC MUST attempt to get the instruction file.
+ def should_use_instruction_file(self) -> bool:
+ """Check if instruction file should be used for decryption.
+
+ Returns:
+ bool: True if instruction file should be fetched
+ """
+ # V3 with content keys but no encrypted data key -> instruction file
+ if self.is_v3_in_object_metadata():
+ return True
+
+ # No version keys at all -> try instruction file for V1/V2
+ has_any_key = (
+ self.encrypted_data_key_v1 is not None
+ or self.encrypted_data_key_v2 is not None
+ or self.encrypted_data_key_v3 is not None
+ )
+ return not has_any_key
+
+
+# Valid S3 Encryption Client metadata keys
+VALID_S3EC_METADATA_KEYS = {
+ # V1/V2 format keys
+ "x-amz-key",
+ "x-amz-key-v2",
+ "x-amz-wrap-alg",
+ "x-amz-matdesc",
+ "x-amz-iv",
+ "x-amz-cek-alg",
+ "x-amz-tag-len",
+ "x-amz-crypto-instr-file",
+ # V3 format keys (compressed)
+ "x-amz-c",
+ "x-amz-3",
+ "x-amz-m",
+ "x-amz-t",
+ "x-amz-w",
+ "x-amz-d",
+ "x-amz-i",
+}
diff --git a/src/s3_encryption/pipelines.py b/src/s3_encryption/pipelines.py
new file mode 100644
index 00000000..ca200a7f
--- /dev/null
+++ b/src/s3_encryption/pipelines.py
@@ -0,0 +1,805 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Encryption and decryption pipelines for S3 Encryption Client.
+
+This module provides pipelines for encrypting objects before they are put into S3
+and decrypting objects after they are retrieved from S3.
+"""
+
+import base64
+import json
+import os
+import threading
+
+from attrs import define, field
+from botocore.response import StreamingBody
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.padding import PKCS7
+
+from ._utils import safe_get_dict
+from .buffered_decrypt import one_shot_decrypt
+from .decryptor import AesCbcDecryptor, AesGcmDecryptor
+from .exceptions import S3EncryptionClientError
+from .instruction_file import fetch_instruction_file
+from .instruction_file_config import InstructionFileConfig
+from .key_derivation import derive_keys, verify_commitment
+from .materials.crypto_materials_manager import AbstractCryptoMaterialsManager
+from .materials.encrypted_data_key import EncryptedDataKey
+from .materials.materials import (
+ AlgorithmSuite,
+ CommitmentPolicy,
+ DecryptionMaterials,
+ EncryptionMaterials,
+)
+from .metadata import ObjectMetadata
+from .stream import DecryptingStream
+
+
+@define
+class PutEncryptedObjectPipeline:
+ """Pipeline for encrypting objects before they are put into S3.
+
+ This pipeline handles only the encryption process for S3 objects.
+ The actual S3 API calls are handled by the S3EncryptionClient.
+ """
+
+ cmm: AbstractCryptoMaterialsManager = field()
+ encryption_algorithm: AlgorithmSuite = field()
+
+ def encrypt(self, plaintext, encryption_context=None):
+ """Encrypt the data before it is stored in S3.
+
+ Args:
+ plaintext (bytes or str): The data to be encrypted
+ encryption_context (dict, optional): Additional context for encryption
+
+ Returns:
+ bytes: The encrypted data
+ dict: Metadata about the encryption to be stored with the object
+ """
+ ##= specification/s3-encryption/encryption.md#content-encryption
+ ##= type=implementation
+ ##% The S3EC MUST use the encryption algorithm configured during
+ ##% [client](./client.md) initialization.
+ enc_mats_request = EncryptionMaterials(
+ encryption_algorithm=self.encryption_algorithm,
+ encryption_context={} if encryption_context is None else encryption_context.copy(),
+ )
+
+ # Get encryption materials from the crypto materials manager
+ enc_mats = self.cmm.get_encryption_materials(enc_mats_request)
+
+ if enc_mats.plaintext_data_key is None:
+ raise RuntimeError("No plaintext data key found!")
+ if enc_mats.encrypted_data_key is None:
+ raise RuntimeError("No encrypted data key found!")
+
+ edk_bytes = enc_mats.encrypted_data_key.encrypted_data_key
+
+ if self.encryption_algorithm == AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY:
+ return self._encrypt_kc_gcm(plaintext, enc_mats, edk_bytes)
+ return self._encrypt_gcm(plaintext, enc_mats, edk_bytes)
+
+ def _encrypt_gcm(self, plaintext, enc_mats, edk_bytes):
+ """Encrypt using ALG_AES_256_GCM_IV12_TAG16_NO_KDF (V2 format)."""
+ ##= specification/s3-encryption/encryption.md#content-encryption
+ ##= type=implementation
+ ##% The client MUST generate an IV or Message ID using the length of the IV
+ ##% or Message ID defined in the algorithm suite.
+ iv = os.urandom(enc_mats.encryption_algorithm.cipher_iv_length_bytes)
+ ##= specification/s3-encryption/encryption.md#alg-aes-256-gcm-iv12-tag16-no-kdf
+ ##= type=implementation
+ ##% The client MUST initialize the cipher, or call an AES-GCM encryption API,
+ ##% with the plaintext data key, the generated IV, and the tag length defined
+ ##% in the Algorithm Suite when encrypting with ALG_AES_256_GCM_IV12_TAG16_NO_KDF.
+ aesgcm = AESGCM(enc_mats.plaintext_data_key)
+ ##= specification/s3-encryption/encryption.md#alg-aes-256-gcm-iv12-tag16-no-kdf
+ ##= type=implementation
+ ##% The client MUST NOT provide any AAD when encrypting with ALG_AES_256_GCM_IV12_TAG16_NO_KDF.
+ encrypted_data = aesgcm.encrypt(nonce=iv, data=plaintext, associated_data=None)
+
+ b64_iv = base64.b64encode(iv).decode("utf-8")
+ b64_edk = base64.b64encode(edk_bytes).decode("utf-8")
+
+ ##= specification/s3-encryption/encryption.md#content-encryption
+ ##= type=implementation
+ ##% The generated IV or Message ID MUST be set or returned from the encryption
+ metadata = ObjectMetadata(
+ encrypted_data_key_v2=b64_edk,
+ encrypted_data_key_algorithm="kms+context",
+ content_iv=b64_iv,
+ content_cipher="AES/GCM/NoPadding",
+ encrypted_data_key_context=enc_mats.encryption_context,
+ )
+
+ return encrypted_data, metadata.to_dict()
+
+ def _encrypt_kc_gcm(self, plaintext, enc_mats, edk_bytes):
+ """Encrypt using ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY (V3 format)."""
+ ##= specification/s3-encryption/encryption.md#content-encryption
+ ##= type=implementation
+ ##% The client MUST generate an IV or Message ID using the length of the IV
+ ##% or Message ID defined in the algorithm suite.
+ algorithm_suite = enc_mats.encryption_algorithm
+ message_id = os.urandom(algorithm_suite.commitment_nonce_length_bytes)
+
+ ##= specification/s3-encryption/encryption.md#alg-aes-256-gcm-hkdf-sha512-commit-key
+ ##= type=implementation
+ ##% The client MUST use HKDF to derive the key commitment value and the derived
+ ##% encrypting key as described in [Key Derivation](key-derivation.md).
+ derived_encryption_key, commit_key = derive_keys(
+ enc_mats.plaintext_data_key, message_id, algorithm_suite
+ )
+
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% The client MUST initialize the cipher, or call an AES-GCM encryption API, with the derived encryption key, an IV containing only bytes with the value 0x01,
+ ##% and the tag length defined in the Algorithm Suite when encrypting or decrypting with ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% The client MUST set the AAD to the Algorithm Suite ID represented as bytes.
+ aesgcm = AESGCM(derived_encryption_key)
+ encrypted_data = aesgcm.encrypt(
+ nonce=algorithm_suite.kc_gcm_iv,
+ data=plaintext,
+ associated_data=algorithm_suite.suite_id_bytes,
+ )
+
+ b64_edk = base64.b64encode(edk_bytes).decode("utf-8")
+ b64_message_id = base64.b64encode(message_id).decode("utf-8")
+ b64_commit_key = base64.b64encode(commit_key).decode("utf-8")
+
+ ##= specification/s3-encryption/encryption.md#content-encryption
+ ##= type=implementation
+ ##% The generated IV or Message ID MUST be set or returned from the encryption
+ ##% process such that it can be included in the content metadata.
+ ##= specification/s3-encryption/encryption.md#alg-aes-256-gcm-hkdf-sha512-commit-key
+ ##= type=implementation
+ ##% The derived key commitment value MUST be set or returned from the encryption
+ ##% process such that it can be included in the content metadata.
+ metadata = ObjectMetadata(
+ content_cipher_v3=str(algorithm_suite.suite_id),
+ encrypted_data_key_algorithm_v3="12",
+ encrypted_data_key_v3=b64_edk,
+ message_id_v3=b64_message_id,
+ key_commitment_v3=b64_commit_key,
+ encryption_context_v3=(
+ enc_mats.encryption_context if enc_mats.encryption_context else None
+ ),
+ )
+
+ return encrypted_data, metadata.to_dict()
+
+
+##= specification/s3-encryption/client.md#optional-api-operations
+##= type=implementation
+##% UploadPart MUST encrypt each part.
+##= specification/s3-encryption/client.md#optional-api-operations
+##= type=implementation
+##% Each part MUST be encrypted in sequence.
+##= specification/s3-encryption/client.md#optional-api-operations
+##= type=implementation
+##% Each part MUST be encrypted using the same cipher instance for each part.
+@define
+class MultipartUploadPipeline:
+ """Pipeline for encrypting multipart uploads.
+
+ Manages a single AES-GCM cipher instance shared across all parts.
+ Parts MUST be uploaded in sequence (1, 2, 3, ...).
+ """
+
+ cmm: AbstractCryptoMaterialsManager = field()
+ encryption_algorithm: AlgorithmSuite = field()
+ encryption_context: dict = field(factory=dict)
+ _encryptor: object = field(init=False, default=None)
+ _metadata: dict = field(init=False, factory=dict)
+ _next_part: int = field(init=False, default=1)
+ _has_final_part_been_seen: bool = field(init=False, default=False)
+ _lock: threading.Lock = field(init=False, factory=threading.Lock)
+ # Cached ciphertext for the most recently encrypted part, enabling retries
+ # if the S3 upload_part call fails after encryption has already advanced.
+ _last_encrypted_part: int = field(init=False, default=0)
+ _last_encrypted_ciphertext: bytes | None = field(init=False, default=None)
+
+ def __attrs_post_init__(self):
+ """Obtain encryption materials and initialize the streaming cipher."""
+ enc_mats_request = EncryptionMaterials(
+ encryption_algorithm=self.encryption_algorithm,
+ encryption_context=self.encryption_context.copy(),
+ )
+ enc_mats = self.cmm.get_encryption_materials(enc_mats_request)
+ if enc_mats.plaintext_data_key is None:
+ raise S3EncryptionClientError("No plaintext data key found!")
+ if enc_mats.encrypted_data_key is None:
+ raise S3EncryptionClientError("No encrypted data key found!")
+
+ edk_bytes = enc_mats.encrypted_data_key.encrypted_data_key
+
+ if self.encryption_algorithm == AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY:
+ self._init_kc_gcm(enc_mats, edk_bytes)
+ else:
+ self._init_gcm(enc_mats, edk_bytes)
+
+ def _init_gcm(self, enc_mats, edk_bytes):
+ iv = os.urandom(enc_mats.encryption_algorithm.cipher_iv_length_bytes)
+ cipher = Cipher(algorithms.AES(enc_mats.plaintext_data_key), modes.GCM(iv))
+ self._encryptor = cipher.encryptor()
+ self._metadata = ObjectMetadata(
+ encrypted_data_key_v2=base64.b64encode(edk_bytes).decode("utf-8"),
+ encrypted_data_key_algorithm="kms+context",
+ content_iv=base64.b64encode(iv).decode("utf-8"),
+ content_cipher="AES/GCM/NoPadding",
+ encrypted_data_key_context=enc_mats.encryption_context,
+ ).to_dict()
+
+ def _init_kc_gcm(self, enc_mats, edk_bytes):
+ algorithm_suite = enc_mats.encryption_algorithm
+ message_id = os.urandom(algorithm_suite.commitment_nonce_length_bytes)
+ derived_encryption_key, commit_key = derive_keys(
+ enc_mats.plaintext_data_key, message_id, algorithm_suite
+ )
+ cipher = Cipher(
+ algorithms.AES(derived_encryption_key), modes.GCM(algorithm_suite.kc_gcm_iv)
+ )
+ self._encryptor = cipher.encryptor()
+ self._encryptor.authenticate_additional_data(algorithm_suite.suite_id_bytes)
+ self._metadata = ObjectMetadata(
+ content_cipher_v3=str(algorithm_suite.suite_id),
+ encrypted_data_key_algorithm_v3="12",
+ encrypted_data_key_v3=base64.b64encode(edk_bytes).decode("utf-8"),
+ message_id_v3=base64.b64encode(message_id).decode("utf-8"),
+ key_commitment_v3=base64.b64encode(commit_key).decode("utf-8"),
+ encryption_context_v3=(
+ enc_mats.encryption_context if enc_mats.encryption_context else None
+ ),
+ ).to_dict()
+
+ @property
+ def metadata(self):
+ """Return the encryption metadata dict for the multipart upload."""
+ return self._metadata
+
+ @property
+ def has_final_part_been_seen(self):
+ """Return whether the final part has been encrypted."""
+ return self._has_final_part_been_seen
+
+ def encrypt_part(self, part_number, data, is_last=False):
+ """Encrypt a single part. Parts must be sequential starting from 1.
+
+ If called with the same part_number as the most recently encrypted part,
+ returns the cached ciphertext (enabling retries after upload failures).
+
+ Args:
+ part_number: The 1-based part number.
+ data: The plaintext bytes for this part.
+ is_last: If True, finalizes the cipher and appends the GCM auth tag.
+
+ Returns:
+ The encrypted ciphertext bytes for this part.
+ """
+ with self._lock:
+ # Allow retry of the last encrypted part
+ if part_number == self._last_encrypted_part:
+ return self._last_encrypted_ciphertext
+
+ if self._has_final_part_been_seen:
+ raise S3EncryptionClientError("Cannot encrypt more parts after the final part.")
+ if part_number != self._next_part:
+ raise S3EncryptionClientError(
+ f"Parts must be uploaded in sequence. Expected part {self._next_part}, "
+ f"got {part_number}."
+ )
+ if isinstance(data, str):
+ data = data.encode("utf-8")
+ self._next_part += 1
+
+ ciphertext = self._encryptor.update(data)
+
+ if is_last:
+ self._encryptor.finalize()
+ ciphertext += self._encryptor.tag
+ self._has_final_part_been_seen = True
+
+ self._last_encrypted_part = part_number
+ self._last_encrypted_ciphertext = ciphertext
+
+ return ciphertext
+
+
+@define
+class GetEncryptedObjectPipeline:
+ """Pipeline for decrypting objects after they are retrieved from S3.
+
+ This pipeline handles only the decryption process for S3 objects.
+ The actual S3 API calls are handled by the S3EncryptionClient.
+ """
+
+ cmm: AbstractCryptoMaterialsManager = field()
+ commitment_policy: CommitmentPolicy = field()
+ s3_client: object = field(default=None)
+ enable_legacy_unauthenticated_modes: bool = field(default=False)
+ instruction_file_config: InstructionFileConfig = field(factory=InstructionFileConfig)
+
+ # Map content cipher metadata values to AlgorithmSuite
+ _CONTENT_CIPHER_TO_ALGORITHM_SUITE = {
+ "AES/CBC/PKCS5Padding": AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF,
+ "AES/GCM/NoPadding": AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF,
+ "115": AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ }
+
+ def _determine_algorithm_suite(self, metadata) -> AlgorithmSuite:
+ """Determine the algorithm suite from object metadata.
+
+ V1 objects are always CBC.
+ V2/V3 objects check x-amz-cek-alg / x-amz-c to determine the content algorithm.
+ """
+ if metadata.is_v1_format():
+ ##= specification/s3-encryption/data-format/content-metadata.md#algorithm-suite-and-message-format-version-compatibility
+ ##= type=citation
+ ##% Objects encrypted with ALG_AES_256_CBC_IV16_NO_KDF MAY use either the V1 or V2 message format version.
+ return AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF
+
+ if metadata.is_v2_format():
+ cek_alg = metadata.content_cipher
+ if cek_alg is None:
+ raise S3EncryptionClientError(
+ "V2 format object missing required x-amz-cek-alg metadata."
+ )
+ suite = self._CONTENT_CIPHER_TO_ALGORITHM_SUITE.get(cek_alg)
+ if suite is None:
+ raise S3EncryptionClientError(f"Unknown content encryption algorithm: {cek_alg}")
+ return suite
+
+ if metadata.is_v3_format():
+ cek_alg = metadata.content_cipher_v3
+ if cek_alg is None:
+ raise S3EncryptionClientError("V3 format object missing required x-amz-c metadata.")
+ suite = self._CONTENT_CIPHER_TO_ALGORITHM_SUITE.get(cek_alg)
+ if suite is None:
+ raise S3EncryptionClientError(f"Unknown content encryption algorithm: {cek_alg}")
+ return suite
+
+ raise S3EncryptionClientError("Unable to determine S3 Encryption Client message format.")
+
+ def decrypt(
+ self,
+ response,
+ instruction_suffix,
+ enable_delayed_authentication,
+ encryption_context=None,
+ bucket=None,
+ key=None,
+ ) -> StreamingBody:
+ """Decrypt the data after it is retrieved from S3.
+
+ Args:
+ response (dict): The response from S3 containing the encrypted data and metadata
+ instruction_suffix (str): suffix for instruction file
+ enable_delayed_authentication (bool): If True, release plaintext before GCM tag verification.
+ encryption_context (dict, optional): Additional context for decryption
+ bucket (str, optional): S3 bucket name (required for instruction file)
+ key (str, optional): S3 object key (required for instruction file)
+
+ Returns:
+ A botocore.response.StreamingBody of plain-text
+ """
+ # Convert the metadata dictionary to an ObjectMetadata instance
+ streaming_body: StreamingBody = response.get("Body")
+ content_length = response.get("ContentLength")
+ encryption_metadata = safe_get_dict(response, "Metadata")
+ metadata = ObjectMetadata.from_dict(encryption_metadata)
+
+ # Use empty dict if encryption_context is None
+ if encryption_context is None:
+ encryption_context = {}
+
+ # Check if we need to fetch instruction file
+ if metadata.should_use_instruction_file():
+ if self.instruction_file_config.disable_get_object:
+ raise S3EncryptionClientError(
+ "Exception encountered while fetching Instruction File. "
+ "Ensure the object you are attempting to decrypt has been encrypted "
+ "using the S3 Encryption Client and instruction files are enabled. "
+ f"bucket: {bucket}\n key: {key}"
+ )
+
+ if self.s3_client is None:
+ raise S3EncryptionClientError("s3_client required to fetch instruction file")
+ if bucket is None or key is None:
+ raise S3EncryptionClientError("Bucket and key required to fetch instruction file")
+ if instruction_suffix is None:
+ raise S3EncryptionClientError(
+ "instruction_suffix required to fetch instruction file"
+ )
+
+ instruction_key = key + instruction_suffix
+ instruction_metadata = fetch_instruction_file(self.s3_client, bucket, instruction_key)
+
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ ##= type=implementation
+ ##% - The V3 message format MUST NOT store the mapkey "x-amz-c" and its value in the Instruction File.
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ ##= type=implementation
+ ##% - The V3 message format MUST NOT store the mapkey "x-amz-d" and its value in the Instruction File.
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files
+ ##= type=implementation
+ ##% - The V3 message format MUST NOT store the mapkey "x-amz-i" and its value in the Instruction File.
+ v3_object_metadata_exclusive_keys = {
+ ObjectMetadata.CONTENT_CIPHER_V3,
+ ObjectMetadata.KEY_COMMITMENT_V3,
+ ObjectMetadata.MESSAGE_ID_V3,
+ }
+ forbidden_keys_in_instruction = (
+ set(instruction_metadata.keys()) & v3_object_metadata_exclusive_keys
+ )
+ if forbidden_keys_in_instruction:
+ raise S3EncryptionClientError(
+ "Instruction file is tampered, instruction file contains object metadata "
+ f"exclusive mapkeys: {forbidden_keys_in_instruction}. "
+ f"bucket: {bucket}\n key:{key}\n instruction_file:{instruction_key}"
+ )
+
+ instruction_metadata.update(encryption_metadata)
+ metadata = ObjectMetadata.from_dict(instruction_metadata)
+ ##= specification/s3-encryption/data-format/metadata-strategy.md#v1-v2-instruction-files
+ ##= type=implementation
+ ##% In the V1/V2 message format, all of the content metadata
+ ##% MUST be stored in the Instruction File.
+ if metadata.is_v1_format() or metadata.is_v2_format():
+ object_metadata = ObjectMetadata.from_dict(encryption_metadata)
+ if not (
+ object_metadata.content_cipher is None
+ and object_metadata.content_iv is None
+ and object_metadata.encrypted_data_key_algorithm is None
+ ):
+ raise S3EncryptionClientError(
+ "Content metadata found in object metadata for V1 or V2 message format "
+ "BUT Instruction File is being used. This is an illegal combination. "
+ f"bucket: {bucket}\n key:{key}\n instruction_file:{instruction_key}"
+ )
+
+ # Determine the algorithm suite from the metadata
+ algorithm_suite = self._determine_algorithm_suite(metadata)
+
+ # Reject metadata that contains keys from multiple format versions.
+ # This prevents format confusion attacks where an attacker injects
+ # V2 keys via an instruction file to bypass V3 key-commitment verification.
+ if metadata.has_exclusive_key_collision():
+ raise S3EncryptionClientError(
+ "Object metadata contains keys from multiple format versions. "
+ "The object or its instruction file may have been tampered with."
+ )
+
+ # Also reject V2 format metadata that contains V3 content keys.
+ # In the instruction file injection scenario, the attacker replaces
+ # V3 EDK keys with V2 keys, but V3 content keys (x-amz-c, x-amz-d,
+ # x-amz-i) remain from the object metadata. This combination is
+ # never produced by legitimate encryption.
+ if metadata.is_v2_format() and (
+ metadata.content_cipher_v3 is not None
+ or metadata.key_commitment_v3 is not None
+ or metadata.message_id_v3 is not None
+ ):
+ raise S3EncryptionClientError( # pragma: no cover — only reachable via instruction file merge; covered by TestInstructionFileFormatConfusion
+ "Object metadata contains V2 format keys alongside V3 content keys. "
+ "The object or its instruction file may have been tampered with."
+ )
+
+ # Determine which format we're dealing with and get decryption materials
+ if metadata.is_v1_format():
+ dec_materials = self._decrypt_v1(metadata, encryption_context)
+ elif metadata.is_v2_format():
+ dec_materials = self._decrypt_v2(metadata, encryption_context)
+ elif metadata.is_v3_format():
+ dec_materials = self._decrypt_v3(metadata, encryption_context)
+ else:
+ raise S3EncryptionClientError(
+ "Unable to determine S3 Encryption Client message format."
+ )
+
+ dec_materials.algorithm_suite = algorithm_suite
+
+ ##= specification/s3-encryption/decryption.md#cbc-decryption
+ ##= type=implementation
+ ##% If an object is encrypted with ALG_AES_256_CBC_IV16_NO_KDF and
+ ##% [legacy unauthenticated algorithm suites](#legacy-decryption) is NOT enabled,
+ ##% the S3EC MUST throw an error which details that client was
+ ##% not configured to decrypt objects with ALG_AES_256_CBC_IV16_NO_KDF.
+ if algorithm_suite.is_legacy and not self.enable_legacy_unauthenticated_modes: # noqa: SIM102
+ ##= specification/s3-encryption/decryption.md#legacy-decryption
+ ##= type=implementation
+ ##% The S3EC MUST NOT decrypt objects encrypted using legacy unauthenticated algorithm suites
+ ##% unless specifically configured to do so.
+ ##= specification/s3-encryption/decryption.md#legacy-decryption
+ ##= type=implementation
+ ##% If the S3EC is not configured to enable legacy unauthenticated content decryption,
+ ##% the client MUST throw an exception when attempting to decrypt an object encrypted
+ ##% with a legacy unauthenticated algorithm suite.
+ raise S3EncryptionClientError(
+ "Cannot decrypt object encrypted with ALG_AES_256_CBC_IV16_NO_KDF. "
+ "The S3 Encryption Client is not configured to decrypt objects using "
+ "legacy unauthenticated algorithm suites. "
+ "Set enable_legacy_unauthenticated_modes=True to allow decryption "
+ "of objects encrypted with CBC."
+ )
+
+ ##= specification/s3-encryption/decryption.md#key-commitment
+ ##= type=implementation
+ ##% The S3EC MUST validate the algorithm suite used for decryption against the
+ ##% key commitment policy before attempting to decrypt the content ciphertext.
+ ##= specification/s3-encryption/decryption.md#key-commitment
+ ##= type=implementation
+ ##% If the commitment policy requires decryption using a committing algorithm suite,
+ ##% and the algorithm suite associated with the object does not support key commitment,
+ ##% then the S3EC MUST throw an exception.
+ ##= specification/s3-encryption/key-commitment.md#commitment-policy
+ ##= type=implementation
+ ##% When the commitment policy is REQUIRE_ENCRYPT_REQUIRE_DECRYPT, the S3EC MUST NOT allow decryption using algorithm suites which do not support key commitment.
+ if (
+ self.commitment_policy == CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT
+ and not dec_materials.algorithm_suite.supports_key_commitment
+ ):
+ raise S3EncryptionClientError(
+ "Configuration conflict: cannot decrypt non-key-committing object "
+ "when commitment policy is REQUIRE_ENCRYPT_REQUIRE_DECRYPT. "
+ "Use REQUIRE_ENCRYPT_ALLOW_DECRYPT or FORBID_ENCRYPT_ALLOW_DECRYPT "
+ "to allow decryption of non-committing objects."
+ )
+
+ # The FORBID_ENCRYPT_ALLOW_DECRYPT and REQUIRE_ENCRYPT_ALLOW_DECRYPT policies
+ # allow decryption with non-committing algorithm suites — no additional check needed.
+ ##= specification/s3-encryption/key-commitment.md#commitment-policy
+ ##= type=implementation
+ ##% When the commitment policy is FORBID_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST allow decryption using algorithm suites which do not support key commitment.
+ ##= specification/s3-encryption/key-commitment.md#commitment-policy
+ ##= type=implementation
+ ##% When the commitment policy is REQUIRE_ENCRYPT_ALLOW_DECRYPT, the S3EC MUST allow decryption using algorithm suites which do not support key commitment.
+
+ if enable_delayed_authentication is None:
+ raise S3EncryptionClientError("enable_delayed_authentication must be explicitly set")
+
+ # Build decryptor and return streaming wrapper based on algorithm suite
+ match dec_materials.algorithm_suite:
+ case AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF:
+ return self._decrypt_cbc_streaming(dec_materials, streaming_body, content_length)
+ case AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF:
+ return self._decrypt_gcm_streaming(
+ dec_materials,
+ streaming_body,
+ enable_delayed_authentication,
+ content_length,
+ )
+ case AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY:
+ return self._decrypt_kc_gcm_streaming(
+ dec_materials,
+ metadata,
+ streaming_body,
+ enable_delayed_authentication,
+ content_length,
+ )
+ case _:
+ raise S3EncryptionClientError("Unknown algorithm suite!")
+
+ @staticmethod
+ def _decrypt_cbc_streaming(dec_materials, streaming_body, content_length):
+ """Decrypt content encrypted with ALG_AES_256_CBC_IV16_NO_KDF.
+
+ CBC is always streamed (no buffered mode) since it has no auth tag.
+ """
+ ##= specification/s3-encryption/decryption.md#cbc-decryption
+ ##= type=implementation
+ ##% If an object is encrypted with ALG_AES_256_CBC_IV16_NO_KDF and
+ ##% [legacy unauthenticated algorithm suites](#legacy-decryption) is enabled,
+ ##% then the S3EC MUST create a cipher with AES in CBC Mode with PKCS5Padding or
+ ##% PKCS7Padding compatible padding for a 16-byte block cipher
+ ##% (example: for the Java JCE, this is "AES/CBC/PKCS5Padding").
+ ##= specification/s3-encryption/decryption.md#cbc-decryption
+ ##= type=implementation
+ ##% If the cipher object cannot be created as described above,
+ ##% Decryption MUST fail.
+ ##= specification/s3-encryption/decryption.md#cbc-decryption
+ ##= type=implementation
+ ##% The error SHOULD detail why the cipher could not be initialized
+ ##% (such as CBC or PKCS5Padding is not supported by the underlying crypto provider).
+ cipher = Cipher(
+ algorithms.AES(dec_materials.plaintext_data_key),
+ modes.CBC(dec_materials.iv),
+ )
+ # Remove PKCS7 padding (compatible with PKCS5Padding for 16-byte block ciphers)
+ unpadder = PKCS7(dec_materials.algorithm_suite.cipher_block_size_bits).unpadder()
+ decryptor = AesCbcDecryptor(cipher.decryptor(), unpadder, content_length=content_length)
+ return DecryptingStream(streaming_body, decryptor, content_length=content_length)
+
+ @staticmethod
+ def _decrypt_gcm_streaming(
+ dec_materials, streaming_body, enable_delayed_authentication, content_length
+ ):
+ """Decrypt content encrypted with ALG_AES_256_GCM_IV12_TAG16_NO_KDF."""
+ ##= specification/s3-encryption/encryption.md#alg-aes-256-gcm-iv12-tag16-no-kdf
+ ##= type=implementation
+ ##% The client MUST NOT provide any AAD when encrypting with
+ ##% ALG_AES_256_GCM_IV12_TAG16_NO_KDF.
+ cipher = Cipher(
+ algorithms.AES(dec_materials.plaintext_data_key), modes.GCM(dec_materials.iv)
+ )
+ decryptor = AesGcmDecryptor(
+ cipher.decryptor(),
+ tag_length=dec_materials.algorithm_suite.cipher_tag_length_bytes,
+ content_length=content_length,
+ )
+ if enable_delayed_authentication:
+ ##= specification/s3-encryption/client.md#enable-delayed-authentication
+ ##= type=implementation
+ ##% When enabled, the S3EC MAY release plaintext from a stream which has not been authenticated.
+ return DecryptingStream(streaming_body, decryptor, content_length=content_length)
+ ##= specification/s3-encryption/client.md#enable-delayed-authentication
+ ##= type=implementation
+ ##% When disabled the S3EC MUST NOT release plaintext from a stream which has not been authenticated.
+ return one_shot_decrypt(streaming_body, decryptor)
+
+ @staticmethod
+ def _decrypt_kc_gcm_streaming(
+ dec_materials, metadata, streaming_body, enable_delayed_authentication, content_length
+ ):
+ """Decrypt content encrypted with ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.
+
+ Performs HKDF key derivation, key commitment verification, then returns
+ a streaming decryptor.
+ """
+ message_id = base64.b64decode(metadata.message_id_v3)
+ stored_commitment = base64.b64decode(metadata.key_commitment_v3)
+
+ ##= specification/s3-encryption/decryption.md#decrypting-with-commitment
+ ##= type=implementation
+ ##% When using an algorithm suite which supports key commitment, the client MUST verify
+ ##% that the [derived key commitment](./key-derivation.md#hkdf-operation) contains the
+ ##% same bytes as the stored key commitment retrieved from the stored object's metadata.
+ ##= specification/s3-encryption/decryption.md#decrypting-with-commitment
+ ##= type=implementation
+ ##% When using an algorithm suite which supports key commitment, the client MUST verify the key commitment values match before deriving
+ ##% the [derived encryption key](./key-derivation.md#hkdf-operation).
+ derived_encryption_key, derived_commitment = derive_keys(
+ dec_materials.plaintext_data_key, message_id, dec_materials.algorithm_suite
+ )
+ verify_commitment(stored_commitment, derived_commitment)
+
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% When encrypting or decrypting with ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ ##% the IV used in the AES-GCM content encryption/decryption MUST consist entirely of bytes with the value 0x01.
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% The IV's total length MUST match the IV length defined by the algorithm suite.
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% The client MUST initialize the cipher, or call an AES-GCM encryption API, with the derived encryption key, an IV containing only bytes with the value 0x01,
+ ##% and the tag length defined in the Algorithm Suite when encrypting or decrypting with ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY.
+ ##= specification/s3-encryption/key-derivation.md#hkdf-operation
+ ##= type=implementation
+ ##% The client MUST set the AAD to the Algorithm Suite ID represented as bytes.
+ cipher = Cipher(
+ algorithms.AES(derived_encryption_key),
+ modes.GCM(dec_materials.algorithm_suite.kc_gcm_iv),
+ )
+ cipher_decryptor = cipher.decryptor()
+ cipher_decryptor.authenticate_additional_data(dec_materials.algorithm_suite.suite_id_bytes)
+ decryptor = AesGcmDecryptor(
+ cipher_decryptor,
+ tag_length=dec_materials.algorithm_suite.cipher_tag_length_bytes,
+ content_length=content_length,
+ )
+ if enable_delayed_authentication:
+ return DecryptingStream(streaming_body, decryptor, content_length=content_length)
+ ##= specification/s3-encryption/client.md#enable-delayed-authentication
+ ##= type=implementation
+ ##% When disabled the S3EC MUST NOT release plaintext from a stream which has not been authenticated.
+ return one_shot_decrypt(streaming_body, decryptor)
+
+ def _decrypt_v2(self, metadata, encryption_context) -> DecryptionMaterials:
+ """Prepare V2 decryption materials."""
+ return self._decrypt_v1_v2(
+ iv_b64=metadata.content_iv,
+ edk_b64=metadata.encrypted_data_key_v2,
+ wrap_alg=metadata.encrypted_data_key_algorithm,
+ stored_context=metadata.encrypted_data_key_context or {},
+ encryption_context=encryption_context,
+ )
+
+ def _decrypt_v1(self, metadata, encryption_context) -> DecryptionMaterials:
+ """Prepare V1 decryption materials."""
+ return self._decrypt_v1_v2(
+ iv_b64=metadata.content_iv,
+ edk_b64=metadata.encrypted_data_key_v1,
+ wrap_alg=metadata.encrypted_data_key_algorithm,
+ stored_context=metadata.encrypted_data_key_context or {},
+ encryption_context=encryption_context,
+ )
+
+ def _decrypt_v1_v2(
+ self, iv_b64, edk_b64, wrap_alg, stored_context, encryption_context
+ ) -> DecryptionMaterials:
+ """Shared logic for preparing V1/V2 decryption materials."""
+ iv_bytes = base64.b64decode(iv_b64)
+ edk_bytes = base64.b64decode(edk_b64)
+
+ encrypted_data_key = EncryptedDataKey(
+ key_provider_id=b"S3Keyring",
+ key_provider_info=wrap_alg,
+ encrypted_data_key=edk_bytes,
+ )
+
+ dec_materials = DecryptionMaterials(
+ iv=iv_bytes,
+ encrypted_data_keys=[encrypted_data_key],
+ encryption_context_stored=stored_context,
+ encryption_context_from_request=encryption_context,
+ )
+
+ return self.cmm.decrypt_materials(dec_materials)
+
+ ##= specification/s3-encryption/data-format/content-metadata.md#v3-only
+ ##% The V3 format uses compression here such that each wrapping algorithm is represented by a two digit string.
+ ##= specification/s3-encryption/data-format/content-metadata.md#v3-only
+ ##% - The wrapping algorithm value "02" MUST be translated to AES/GCM upon retrieval, and vice versa on write.
+ ##= specification/s3-encryption/data-format/content-metadata.md#v3-only
+ ##% - The wrapping algorithm value "12" MUST be translated to kms+context upon retrieval, and vice versa on write.
+ ##= specification/s3-encryption/data-format/content-metadata.md#v3-only
+ ##% - The wrapping algorithm value "22" MUST be translated to RSA-OAEP-SHA1 upon retrieval, and vice versa on write.
+ _V3_WRAP_ALG_MAP = {
+ "02": "AES/GCM",
+ "12": "kms+context",
+ "22": "RSA-OAEP-SHA1",
+ }
+
+ def _decrypt_v3(self, metadata, encryption_context) -> DecryptionMaterials:
+ """Prepare V3 decryption materials."""
+ edk_bytes = base64.b64decode(metadata.encrypted_data_key_v3)
+
+ # Map V3 compressed wrapping algorithm to canonical key_provider_info
+ raw_wrap_alg = metadata.encrypted_data_key_algorithm_v3 or "12"
+ wrap_alg = self._V3_WRAP_ALG_MAP.get(raw_wrap_alg)
+ if wrap_alg is None:
+ raise S3EncryptionClientError(
+ f"Unknown V3 wrapping algorithm: '{raw_wrap_alg}'. "
+ f"Valid values are: {list(self._V3_WRAP_ALG_MAP.keys())}. "
+ f"The object metadata may have been tampered with."
+ )
+
+ encrypted_data_key = EncryptedDataKey(
+ key_provider_id=b"S3Keyring",
+ key_provider_info=wrap_alg,
+ encrypted_data_key=edk_bytes,
+ )
+
+ ##= specification/s3-encryption/data-format/content-metadata.md#v3-only
+ ##% The Encryption Context value MUST be used for wrapping algorithm `kms+context` or `12`.
+ ##= specification/s3-encryption/data-format/content-metadata.md#v3-only
+ ##% The Material Description MUST be used for wrapping algorithms `AES/GCM` (`02`) and `RSA-OAEP-SHA1` (`22`).
+ # For kms+context, the stored context comes from x-amz-t (encryption_context_v3).
+ # For AES/GCM and RSA-OAEP-SHA1, it comes from x-amz-m (mat_desc_v3).
+ stored_context = {}
+ if wrap_alg == "kms+context":
+ raw_ctx = metadata.encryption_context_v3
+ elif wrap_alg in ("AES/GCM", "RSA-OAEP-SHA1"):
+ raw_ctx = metadata.mat_desc_v3
+ else:
+ raise S3EncryptionClientError( # pragma: no cover — defense in depth, unreachable
+ f"Unexpected V3 wrapping algorithm for context selection: '{wrap_alg}'. "
+ f"The object metadata may have been tampered with."
+ )
+
+ if raw_ctx is not None:
+ if isinstance(raw_ctx, dict):
+ stored_context = raw_ctx
+ elif isinstance(raw_ctx, str):
+ stored_context = json.loads(raw_ctx)
+
+ dec_materials = DecryptionMaterials(
+ encrypted_data_keys=[encrypted_data_key],
+ encryption_context_stored=stored_context,
+ encryption_context_from_request=encryption_context,
+ )
+
+ return self.cmm.decrypt_materials(dec_materials)
diff --git a/src/s3_encryption/stream.py b/src/s3_encryption/stream.py
new file mode 100644
index 00000000..a4e85a74
--- /dev/null
+++ b/src/s3_encryption/stream.py
@@ -0,0 +1,189 @@
+# Copyright Amazon.com Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Streaming decryption support for S3 Encryption Client."""
+
+import io
+
+from attrs import define, field
+from botocore.exceptions import IncompleteReadError
+from botocore.response import StreamingBody
+
+from .decryptor import Decryptor
+
+##= specification/s3-encryption/client.md#set-buffer-size
+##= type=exception
+##= reason=Optional Feature that is a two-way door to implement later
+##% The S3EC SHOULD accept a configurable buffer size which refers to the maximum ciphertext length in bytes to store in memory when Delayed Authentication mode is disabled.
+##= specification/s3-encryption/client.md#set-buffer-size
+##= type=exception
+##= reason=Optional Feature that is a two-way door to implement later
+##% If Delayed Authentication mode is enabled, and the buffer size has been set to a value other than its default, the S3EC MUST throw an exception.
+##= specification/s3-encryption/client.md#set-buffer-size
+##= type=exception
+##= reason=Optional Feature that is a two-way door to implement later
+##% If Delayed Authentication mode is disabled, and no buffer size is provided, the S3EC MUST set the buffer size to a reasonable default.
+
+
+_DEFAULT_CHUNK_SIZE = 1024
+
+
+##= specification/s3-encryption/client.md#enable-delayed-authentication
+##= type=implementation
+##% When enabled, the S3EC MAY release plaintext from a stream which has not been authenticated.
+# slots=False because StreamingBody extends IOBase which already has __weakref__.
+@define(slots=False)
+class DecryptingStream(StreamingBody):
+ """A stream that releases plaintext incrementally before full authentication.
+
+ Extends botocore's StreamingBody so it can be used as a drop-in replacement
+ for parsed["Body"]. All StreamingBody methods are explicitly overridden.
+ """
+
+ # This stream is ALMOST cipher-agnostic — the Decryptor handles ALMOST all algorithm details.
+ # Ciphertext is fed through decryptor.update() incrementally, and
+ # decryptor.finalize() is called with any trailing data when the body is exhausted.
+ #
+ # ALMOST :: The AES-GCM tag is problematic when combined with iterators that can split
+ # the tag over two reads. To accommodate this, read() has a while loop with 3 return conditions.
+ # See inline comments of read for more details.
+
+ _body: object = field()
+ _decryptor: Decryptor = field()
+ _content_length: int = field()
+ _bytes_consumed: int = field(init=False, default=0)
+ _finalized: bool = field(init=False, default=False)
+
+ def __attrs_post_init__(self): # noqa: D105
+ super().__init__(io.BytesIO(), content_length=self._content_length)
+
+ def readable(self): # noqa: D102
+ return not self._finalized
+
+ def read(self, amt=None):
+ """Read and decrypt ciphertext, releasing plaintext incrementally.
+
+ Args:
+ amt: Number of bytes to read. If None, reads all remaining data.
+
+ Returns:
+ bytes: Decrypted plaintext bytes.
+ """
+ if self._finalized:
+ return b""
+
+ # Loop until the decryptor produces non-empty plaintext.
+ # The GCM decryptor's tail buffer may absorb small reads entirely
+ # (returning b"" from update) while it holds back the trailing auth
+ # tag. Looping prevents callers from seeing spurious empty bytes
+ # mid-stream, which would break `while chunk := stream.read(amt)`.
+ result = b""
+ while not result:
+ remaining = self._content_length - self._bytes_consumed
+ if remaining <= 0:
+ # All content_length bytes consumed — finalize with no extra data.
+ return self._finalize(b"")
+
+ # Never read past content_length; cap at amt if provided.
+ to_read = remaining if amt is None else min(amt, remaining)
+ raw = self._body.read(to_read)
+
+ if not raw:
+ # Underlying stream exhausted early — finalize with what we have.
+ return self._finalize(b"")
+
+ self._bytes_consumed += len(raw)
+ remaining = self._content_length - self._bytes_consumed
+
+ if remaining <= 0:
+ # This is the last chunk — pass it to finalize so the decryptor
+ # can split off the GCM tag (or flush CBC padding) and verify.
+ return self._finalize(raw)
+
+ # Feed ciphertext to the decryptor. For GCM, the tail buffer holds
+ # back the last tag_length bytes, so update() may return b"" if
+ # the chunk was entirely absorbed into the buffer.
+ result = self._decryptor.update(raw)
+ return result
+
+ def _finalize(self, trailing_data):
+ """Finalize decryption with any trailing data."""
+ if self._finalized:
+ return b""
+ self._finalized = True
+ plaintext = self._decryptor.finalize(trailing_data)
+ self._verify_content_length()
+ return plaintext
+
+ def readinto(self, b):
+ """Read bytes into a pre-allocated, writable bytes-like object b.
+
+ Returns the number of bytes decrypted.
+ Note: CBC Padding and GCM tag will be removed, so bytes read MAYBE greater than bytes decrypted.
+ """
+ data = self.read(len(b))
+ n = len(data)
+ b[:n] = data
+ return n
+
+ def readlines(self): # noqa: D102
+ return self.read().splitlines(True)
+
+ def __iter__(self):
+ """Return an iterator to yield 1k chunks from the decryption stream."""
+ return self
+
+ def __next__(self):
+ """Return the next 1k chunk from the decryption stream."""
+ chunk = self.read(_DEFAULT_CHUNK_SIZE)
+ if chunk:
+ return chunk
+ raise StopIteration()
+
+ next = __next__
+
+ def iter_lines(self, chunk_size=_DEFAULT_CHUNK_SIZE, keepends=False):
+ """Return an iterator to yield lines from the decryption stream.
+
+ This is achieved by reading chunk of bytes (of size chunk_size) at a
+ time from the chipher-text stream, decrypting them, and then yielding lines from there.
+ """
+ pending = b""
+ for chunk in self.iter_chunks(chunk_size):
+ lines = (pending + chunk).splitlines(True)
+ for line in lines[:-1]:
+ yield line.splitlines(keepends)[0]
+ pending = lines[-1]
+ if pending:
+ yield pending.splitlines(keepends)[0]
+
+ def iter_chunks(self, chunk_size=_DEFAULT_CHUNK_SIZE):
+ """Return an iterator to yield chunks of chunk_size bytes from the raw stream."""
+ while True:
+ chunk = self.read(chunk_size)
+ if chunk == b"":
+ break
+ yield chunk
+
+ def _verify_content_length(self):
+ """Verify that the decryptor consumed exactly content_length bytes."""
+ if self._decryptor.content_length is not None and not (
+ self._decryptor.amount_read == self._content_length
+ ):
+ raise IncompleteReadError(
+ actual_bytes=self._decryptor.amount_read,
+ expected_bytes=self._decryptor.content_length,
+ )
+
+ def tell(self): # noqa: D102
+ return self._bytes_consumed
+
+ def close(self):
+ """Close the underlying cipher-text stream."""
+ if hasattr(self._body, "close"):
+ self._body.close()
+
+ def __enter__(self): # noqa: D105
+ return self
+
+ def __exit__(self, *args): # noqa: D105
+ self.close()
diff --git a/test-server/Makefile b/test-server/Makefile
new file mode 100644
index 00000000..21b5c98b
--- /dev/null
+++ b/test-server/Makefile
@@ -0,0 +1,158 @@
+# Makefile for S3 Encryption Client Testing
+
+.PHONY: test-servers-all test-servers-start test-servers-run-tests test-servers-stop test-servers-clean test-servers-ci test-servers-check-env test-servers-help
+
+# CI target for GitHub Actions
+test-servers-ci:
+ $(MAKE) build-all-servers
+ $(MAKE) start-all-servers
+ $(MAKE) wait-all-servers
+ $(MAKE) test-servers-run-tests
+ $(MAKE) test-servers-stop
+
+SERVER_DIRS := $(shell find . -maxdepth 1 -type d -name '*-server' | sed 's|^\./||' | $(if $(FILTER),grep -E "$$(echo '$(FILTER)' | sed 's/,/|/g')",cat) | sort)
+
+BUILD_SERVER_TARGETS := $(addprefix build-, $(SERVER_DIRS))
+START_SERVER_TARGETS := $(addprefix start-, $(SERVER_DIRS))
+WAIT_SERVER_TARGETS := $(addprefix wait-, $(SERVER_DIRS))
+
+# Build all servers in parallel
+build-all-servers:
+ @echo "[`date +%H:%M:%S`] Building all servers..."
+ @$(MAKE) $(BUILD_SERVER_TARGETS)
+ @echo "[`date +%H:%M:%S`] All servers built."
+ @echo "Stopping dotnet servers... this is here to speed up CI because if this target is run with -j it will stay open until it shutdown."
+ @dotnet build-server shutdown
+ @echo "[`date +%H:%M:%S`] Dotnet build servers stopped"
+
+$(BUILD_SERVER_TARGETS): build-%:
+ @if [ -f $*/Makefile ]; then \
+ echo "[`date +%H:%M:%S`] Building server in $*..." && \
+ $(MAKE) -C $* build-server && \
+ echo "[`date +%H:%M:%S`] Server $* built successfully"; \
+ else \
+ echo "❌ Error: no Makefile found in $*"; \
+ exit 1; \
+ fi
+
+# Build and start all servers
+test-servers-start:
+ @echo "Building all servers..."
+ $(MAKE) build-all-servers
+ @echo "Starting all servers..."
+ $(MAKE) start-all-servers
+ @echo "Waiting for servers to start..."
+ @for dir in $(SERVER_DIRS); do \
+ echo "Waiting for server in $$dir..."; \
+ $(MAKE) -C $$dir wait-for-server; \
+ done
+
+start-all-servers:
+ @$(MAKE) $(START_SERVER_TARGETS)
+
+$(START_SERVER_TARGETS): start-%:
+ @if [ -f $*/Makefile ]; then \
+ echo "Starting server in $*..." && \
+ $(MAKE) -C $* start-server; \
+ else \
+ echo "❌ Error: no Makefile found in $*"; \
+ exit 1; \
+ fi
+
+wait-all-servers:
+ @echo "Waiting for all servers to be ready..."
+ $(MAKE) $(WAIT_SERVER_TARGETS)
+ @echo "All servers are ready!"
+
+$(WAIT_SERVER_TARGETS): wait-%:
+ @if [ -f $*/Makefile ]; then \
+ echo "Waiting server in $*..." && \
+ $(MAKE) -C $* wait-for-server; \
+ else \
+ echo "❌ Error: no Makefile found in $*"; \
+ exit 1; \
+ fi
+
+
+# Run the Java tests
+test-servers-run-tests:
+ @echo "Running Java tests..."
+ @echo "Exporting environment variables from servers to tests..."
+ @# Extract AWS environment variables from the current shell and pass them to the tests
+ cd java-tests && \
+ AWS_ACCESS_KEY_ID="$$AWS_ACCESS_KEY_ID" \
+ AWS_SECRET_ACCESS_KEY="$$AWS_SECRET_ACCESS_KEY" \
+ AWS_SESSION_TOKEN="$$AWS_SESSION_TOKEN" \
+ AWS_REGION="us-west-2" \
+ ./gradlew --build-cache --info --parallel --no-daemon integ \
+ $(if $(TEST),--tests "$(TEST)",) \
+ -Dtest.filter.servers="$(FILTER)"
+ @echo "Tests completed successfully"
+
+# Stop the servers
+test-servers-stop:
+ @echo "Stopping servers..."
+ @for dir in $(SERVER_DIRS); do \
+ echo "Stopping server in $$dir..."; \
+ $(MAKE) -C $$dir stop-server; \
+ done
+ @echo "Servers stopped"
+
+# Help target
+test-servers-help:
+ @echo "Available targets:"
+ @echo " test-servers-all : Start servers and run tests (default, output to stdout)"
+ @echo " test-servers-ci : Run in CI mode (start servers, run tests, stop servers)"
+ @echo " test-servers-start : Start all servers in parallel"
+ @echo " test-servers-run-tests : Run Java tests"
+ @echo " test-servers-stop : Stop running servers"
+ @echo " test-servers-check-env : Check if required environment variables are set"
+ @echo " test-servers-help : Show this help message"
+
+# Check if required environment variables are set
+test-servers-check-env:
+ @echo "Checking required environment variables..."
+ @if [ -z "$$AWS_ACCESS_KEY_ID" ]; then echo "AWS_ACCESS_KEY_ID is not set"; else echo "AWS_ACCESS_KEY_ID is set"; fi
+ @if [ -z "$$AWS_SECRET_ACCESS_KEY" ]; then echo "AWS_SECRET_ACCESS_KEY is not set"; else echo "AWS_SECRET_ACCESS_KEY is set"; fi
+ @if [ -z "$$AWS_SESSION_TOKEN" ]; then echo "AWS_SESSION_TOKEN is not set"; else echo "AWS_SESSION_TOKEN is set"; fi
+ @if [ -z "$$AWS_REGION" ]; then echo "AWS_REGION is not set (will use us-west-2 as default)"; else echo "AWS_REGION is set to $$AWS_REGION"; fi
+
+TIMEOUT := 360
+
+wait-for-port:
+ @if [ -z "$(PORT)" ]; then \
+ echo "❌ Error: PORT is required"; \
+ exit 1; \
+ fi
+ @echo "Starting to wait for $$PORT to start";
+ @for i in $$(seq 1 $(TIMEOUT)); do \
+ if nc -z localhost $$PORT; then \
+ echo "Ports are open, waiting for servers to initialize..."; \
+ sleep 5; \
+ echo "Server at $$PORT is ready!"; \
+ break; \
+ fi; \
+ if [ $$i -eq $(TIMEOUT) ]; then \
+ echo "Timeout waiting for $$PORT start"; \
+ exit 1; \
+ fi; \
+ echo "Waiting for $$PORT to start ($$i/$(TIMEOUT))..."; \
+ sleep 3; \
+ done
+
+# Here are some helpful curl commands
+# that you can use to test specific test servers:
+test-create-client:
+ @echo $(PORT)
+ @curl -X POST \
+ -H "Content-Type: application/json" \
+ -H "User-Agent: smithy-java/0.0.3 ua/2.1 os/macos#15.5 lang/java#23.0.2" \
+ -d '{"config":{"enableLegacyUnauthenticatedModes":false,"enableDelayedAuthenticationMode":false,"enableLegacyWrappingAlgorithms":false,"keyMaterial":{"kmsKeyId":"arn:aws:kms:us-west-2:370957321024:alias/S3EC-Test-Server-Github-KMS-Key"}}}' \
+ http://localhost:$(PORT)/client
+
+duvet:
+ @echo "Running duvet reports..."
+ @for dir in $(SERVER_DIRS); do \
+ echo "Running make duvet in $$dir..."; \
+ $(MAKE) -C $$dir duvet; \
+ done
diff --git a/test-server/README.md b/test-server/README.md
new file mode 100644
index 00000000..68796f30
--- /dev/null
+++ b/test-server/README.md
@@ -0,0 +1,83 @@
+# S3EC Generalized Robust Test Framework Machine
+
+Or G-RTFM. Or something.
+
+## What?
+
+This is a write-once, run-multiple test server.
+
+## How?
+
+Use Smithy Java roughly as it is intended.
+That is, generate a client and a server which share a common model.
+Then, write more servers, either using the server codegen or parsing the JSON blobject by "hand".
+
+## Running Tests
+
+A Makefile is provided to simplify running the servers and tests. The Makefile handles starting both the Python and Java servers, running the tests, and cleaning up.
+
+### Available Commands
+
+```bash
+# Start servers and run tests (default)
+make
+
+# Run in CI mode (start servers in parallel, run tests, stop servers)
+make ci
+
+# Start Python and Java servers in parallel
+make start-servers
+
+# Start only the Python S3EC V4 server
+make start-python-v4-server
+
+# Run Java tests
+make run-tests
+
+# Stop running servers
+make stop-servers
+
+# Stop servers and clean up logs
+make clean
+
+# Show help message
+make help
+```
+
+The `ci` target is specifically designed for GitHub Actions workflows, ensuring that servers are properly started in parallel, tests are run, and resources are cleaned up afterward.
+
+## Performance Optimizations
+
+Performance optimizations have been implemented to speed up the test-server CI process, which was previously taking over 5 minutes to run. These optimizations include:
+
+- Parallel server startup
+- Gradle build caching and parallel execution
+- Dependency caching in CI
+- JVM optimizations
+
+For detailed information about the optimizations, see [OPTIMIZATION.md](./OPTIMIZATION.md).
+
+### Duvet
+
+To check duvet you need to install Rust.
+Until the latest version of Duvet is release
+
+```bash
+ git clone https://github.com/awslabs/duvet.git /tmp/duvet
+ pushd /tmp/duvet
+ cargo xtask build
+ cargo install --path ./duvet
+ popd rm -rf /tmp/duvet
+```
+
+Inside each test server directory there is a `.duvet` directory that contains a `config.toml`.
+This is the best way to configure `duvet`.
+
+You can adjust the source pattern or comment style as needed.
+Examples:
+
+- `ruby-v2-server/.duvet/config.toml`
+
+There are Makefile targets,
+but you can just run `make duvet` or `duvet report` inside a server directory to run the report.
+To view the report `make view-report-mac` or `open .duvet/reports/report.html`
diff --git a/test-server/cpp-v2-transition-server/.duvet/.gitignore b/test-server/cpp-v2-transition-server/.duvet/.gitignore
new file mode 100644
index 00000000..93956e36
--- /dev/null
+++ b/test-server/cpp-v2-transition-server/.duvet/.gitignore
@@ -0,0 +1,3 @@
+reports/
+requirements/
+specification/
\ No newline at end of file
diff --git a/test-server/cpp-v2-transition-server/CMakeLists.txt b/test-server/cpp-v2-transition-server/CMakeLists.txt
new file mode 100644
index 00000000..b282dbc4
--- /dev/null
+++ b/test-server/cpp-v2-transition-server/CMakeLists.txt
@@ -0,0 +1,39 @@
+cmake_minimum_required(VERSION 3.16)
+project(s3ec-cpp-v2-server)
+
+set(CMAKE_CXX_STANDARD 17)
+
+# Configure AWS SDK build options
+set(BUILD_ONLY "kms;s3;s3-encryption" CACHE STRING "Build only KMS, S3, and S3-encryption components")
+set(ENABLE_TESTING OFF CACHE BOOL "Disable testing")
+set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build static libraries")
+
+# Add AWS SDK as subdirectory
+add_subdirectory(aws-sdk-cpp)
+
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(LIBMICROHTTPD REQUIRED libmicrohttpd)
+
+find_package(nlohmann_json REQUIRED)
+
+add_executable(s3ec-server main.cpp)
+
+target_include_directories(s3ec-server PRIVATE
+ ${LIBMICROHTTPD_INCLUDE_DIRS}
+ /opt/homebrew/include
+)
+
+target_link_directories(s3ec-server PRIVATE
+ ${LIBMICROHTTPD_LIBRARY_DIRS}
+ /opt/homebrew/lib
+)
+
+target_link_libraries(s3ec-server
+ ${LIBMICROHTTPD_LIBRARIES}
+ aws-cpp-sdk-core
+ aws-cpp-sdk-kms
+ aws-cpp-sdk-s3
+ aws-cpp-sdk-s3-encryption
+ nlohmann_json::nlohmann_json
+ uuid
+)
\ No newline at end of file
diff --git a/test-server/cpp-v2-transition-server/Makefile b/test-server/cpp-v2-transition-server/Makefile
new file mode 100644
index 00000000..0383b4d8
--- /dev/null
+++ b/test-server/cpp-v2-transition-server/Makefile
@@ -0,0 +1,37 @@
+# Makefile for S3 Encryption Client Testing
+
+.PHONY: build-server start-server stop-server wait-for-server
+
+PID_FILE := server.pid
+PORT := 8097
+
+build/s3ec-server:
+ mkdir -p build && cd build && cmake ..
+
+build-server: | build/s3ec-server
+ @echo "Building Cpp transition server..."
+ cd build && $(MAKE)
+
+start-server:
+ @echo "Starting Cpp transition server..."
+ cd build && \
+ AWS_ACCESS_KEY_ID="$$AWS_ACCESS_KEY_ID" \
+ AWS_SECRET_ACCESS_KEY="$$AWS_SECRET_ACCESS_KEY" \
+ AWS_SESSION_TOKEN="$$AWS_SESSION_TOKEN" \
+ AWS_REGION="us-west-2" \
+ ./s3ec-server > ../server.log 2>&1 & echo $$! > ../$(PID_FILE)
+ @echo "Cpp transition server starting..."
+
+stop-server:
+ @echo "Stopping server on port $(PORT)..."
+ @lsof -ti:$(PORT) | xargs kill -9 2>/dev/null || true
+ @if [ -f $(PID_FILE) ]; then \
+ pkill -P $$(cat $(PID_FILE)) 2>/dev/null || true; \
+ kill -9 $$(cat $(PID_FILE)) 2>/dev/null || true; \
+ rm -f $(PID_FILE); \
+ fi
+ @rm -f server.log
+ @echo "Server stopped"
+
+wait-for-server:
+ $(MAKE) -C .. wait-for-port PORT=$(PORT)
diff --git a/test-server/cpp-v2-transition-server/README.md b/test-server/cpp-v2-transition-server/README.md
new file mode 100644
index 00000000..8e77feda
--- /dev/null
+++ b/test-server/cpp-v2-transition-server/README.md
@@ -0,0 +1,37 @@
+# C++ S3 Encryption Test Server
+
+Minimal C++ implementation of the S3 Encryption test server.
+
+## Dependencies
+
+- libmicrohttpd
+- AWS SDK for C++
+- nlohmann/json
+- uuid
+
+On MacOS you can
+```bash
+brew install libmicrohttpd nlohmann-json ossp-uuid
+```
+
+## Build
+
+```bash
+mkdir build && cd build
+cmake ..
+make
+```
+
+## Run
+
+```bash
+./s3ec-server
+```
+
+Server runs on localhost:8085
+
+## API Endpoints
+
+- `POST /client` - Create S3 encryption client
+- `GET /object/{bucket}/{key}` - Get encrypted object
+- `PUT /object/{bucket}/{key}` - Put encrypted object
\ No newline at end of file
diff --git a/test-server/cpp-v2-transition-server/aws-sdk-cpp b/test-server/cpp-v2-transition-server/aws-sdk-cpp
new file mode 160000
index 00000000..9110b0ff
--- /dev/null
+++ b/test-server/cpp-v2-transition-server/aws-sdk-cpp
@@ -0,0 +1 @@
+Subproject commit 9110b0ff85094134a4f78316485fd7fe754a2a9c
diff --git a/test-server/cpp-v2-transition-server/main.cpp b/test-server/cpp-v2-transition-server/main.cpp
new file mode 100644
index 00000000..9e9f942d
--- /dev/null
+++ b/test-server/cpp-v2-transition-server/main.cpp
@@ -0,0 +1,748 @@
+/*
+ * S3 Encryption Test Server - C++ V2 Transition
+ *
+ * CONCURRENCY AND SYNCHRONIZATION DESIGN:
+ *
+ * 1. Threading Model:
+ * - Uses MHD_USE_POLL_INTERNALLY with fixed thread pool
+ * - Thread pool size = CPU cores * 2 (auto-detected at startup)
+ * - Threads are reused across connections for efficiency
+ * - I/O multiplexing (poll) distributes connections across thread pool
+ * - All S3 operations are SYNCHRONOUS - server waits for S3 completion before responding
+ * - POLL mechanism avoids FD_SETSIZE=1024 limitation of select()
+ *
+ * 2. Resource Scaling:
+ * - All limits automatically scale with detected CPU count:
+ * * Thread pool size = num_cores * 2
+ * * Connection limit = num_cores * 2
+ * * S3 client maxConnections = num_cores * 2
+ * - Multiplier of 2 accounts for I/O blocking without starving throughput
+ * - Ensures optimal resource usage on any hardware configuration
+ *
+ * 3. Client Cache (client_cache_secret):
+ * - Protected by std::shared_mutex for efficient concurrent access
+ * - get_client() uses shared_lock (multiple threads can read simultaneously)
+ * - set_client() uses unique_lock (exclusive write access)
+ * - This allows concurrent GET/PUT operations without serialization
+ * - UUID-based keys guarantee uniqueness (always insert, never update)
+ *
+ * 4. Memory Management:
+ * - Request body allocated in request_handler (*con_cls = new std::string())
+ * - Body lifetime managed by libmicrohttpd - valid until request_completed()
+ * - All handler functions complete synchronously before returning
+ * - request_completed() safely deletes body after response sent
+ * - No memory leaks under sustained concurrent load
+ *
+ * 5. Synchronous Operation Guarantees:
+ * - GetObject: Waits for S3, reads full response stream, then returns
+ * - PutObject: Waits for S3 operation to complete, then returns
+ * - No async callbacks or background operations
+ * - Client receives response only after S3 operation completes
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using json = nlohmann::json;
+using namespace Aws::S3Encryption;
+using Aws::S3Encryption::Materials::KMSWithContextEncryptionMaterials;
+
+// LRU cache for S3 encryption clients
+// Limits memory and connection pool growth by evicting least recently used clients
+const size_t MAX_CACHED_CLIENTS = 100; // Reasonable limit for concurrent test operations
+
+struct ClientCacheEntry {
+ std::shared_ptr client;
+ std::list::iterator lru_iter;
+};
+
+std::unordered_map client_cache_secret;
+std::list lru_order; // Most recently used at front
+std::shared_timed_mutex client_mutex; // Using shared_timed_mutex (C++14 compatible) for concurrent reads
+
+// Threading configuration - set at startup based on CPU cores
+unsigned int g_thread_pool_size = 8; // Default, will be overwritten in main()
+
+std::string generate_uuid() {
+ uuid_t uuid;
+ uuid_generate(uuid);
+ char uuid_str[37];
+ uuid_unparse(uuid, uuid_str);
+ return std::string(uuid_str);
+}
+
+std::shared_ptr get_client(const std::string &client_id)
+{
+ // Need unique_lock to update LRU order even on reads
+ std::unique_lock lock(client_mutex);
+ auto it = client_cache_secret.find(client_id);
+ if (it == client_cache_secret.end()) {
+ return std::shared_ptr();
+ } else {
+ // Move to front of LRU list (mark as most recently used)
+ lru_order.erase(it->second.lru_iter);
+ lru_order.push_front(client_id);
+ it->second.lru_iter = lru_order.begin();
+
+ return it->second.client;
+ }
+}
+
+void set_client(const std::string &client_id, std::shared_ptr client)
+{
+ // UUID guarantees unique keys - always insert, never update
+ // Still need exclusive lock because std::unordered_map isn't thread-safe for concurrent inserts
+ std::unique_lock lock(client_mutex);
+
+ // Add to front of LRU list (most recently used)
+ lru_order.push_front(client_id);
+
+ ClientCacheEntry entry;
+ entry.client = client;
+ entry.lru_iter = lru_order.begin();
+
+ client_cache_secret.emplace(client_id, entry);
+
+ // Evict least recently used clients if we exceed the limit
+ while (client_cache_secret.size() > MAX_CACHED_CLIENTS) {
+ std::string lru_client_id = lru_order.back();
+ lru_order.pop_back();
+
+ auto evict_it = client_cache_secret.find(lru_client_id);
+ if (evict_it != client_cache_secret.end()) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] [CACHE-EVICT] Evicting client %s (cache size was %zu)\n",
+ lru_client_id.c_str(), client_cache_secret.size());
+ client_cache_secret.erase(evict_it);
+ }
+ }
+
+ fprintf(stderr, "[CPP-V2-TRANSITION] [CACHE-ADD] Added client %s (cache size now %zu)\n",
+ client_id.c_str(), client_cache_secret.size());
+}
+
+std::string get_header_value(struct MHD_Connection *connection,
+ const char *key) {
+ const char *value =
+ MHD_lookup_connection_value(connection, MHD_HEADER_KIND, key);
+ return value ? std::string(value) : "";
+}
+
+MHD_Result send_response(struct MHD_Connection *connection, int status_code,
+ const std::string &content) {
+ struct MHD_Response *response = MHD_create_response_from_buffer(
+ content.length(), (void *)content.data(), MHD_RESPMEM_MUST_COPY);
+ MHD_Result ret = MHD_queue_response(connection, status_code, response);
+ MHD_destroy_response(response);
+ return ret;
+}
+
+std::string make_error(const std::string &message, int status_code) {
+ return "{\"__type\": "
+ "\"software.amazon.encryption.s3#S3EncryptionClientError\", "
+ "\"message\": \"" +
+ message + "\"}";
+}
+
+bool unsupported(std::string& commitmentPolicy, std::string& encryptionAlgorithm)
+{
+ if (encryptionAlgorithm == "ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY") return true;
+ if (commitmentPolicy == "REQUIRE_ENCRYPT_REQUIRE_DECRYPT") return true;
+ if (commitmentPolicy == "REQUIRE_ENCRYPT_ALLOW_DECRYPT") return true;
+ return false;
+}
+
+std::string get_config(json & request, const char * x)
+{
+ if (!request.contains("config")) return "";
+ auto config = request["config"];
+ if (config.contains(x))
+ return config[x];
+ return "";
+}
+
+MHD_Result handle_create_client(struct MHD_Connection *connection,
+ const std::string &body) {
+ // Body is kept alive by *con_cls until request_completed fires, so it's safe to use directly
+ // All operations here are synchronous and complete before returning to caller
+
+ try {
+ json request = json::parse(body);
+ std::string commitmentPolicy = get_config(request, "commitmentPolicy");
+ std::string encryptionAlgorithm = get_config(request, "encryptionAlgorithm");
+
+ if (unsupported(commitmentPolicy, encryptionAlgorithm)) {
+ send_response(connection, 404, "{\"error\":\"Unsupported Option.\"}");
+ return MHD_YES;
+ }
+
+ // Extract all key material types
+ std::string kms_key_id;
+ std::string rsa_key_blob;
+ std::string aes_key_blob;
+
+ if (request["config"]["keyMaterial"].contains("kmsKeyId") &&
+ !request["config"]["keyMaterial"]["kmsKeyId"].is_null()) {
+ kms_key_id = request["config"]["keyMaterial"]["kmsKeyId"];
+ }
+ if (request["config"]["keyMaterial"].contains("rsaKey") &&
+ !request["config"]["keyMaterial"]["rsaKey"].is_null()) {
+ rsa_key_blob = request["config"]["keyMaterial"]["rsaKey"];
+ }
+ if (request["config"]["keyMaterial"].contains("aesKey") &&
+ !request["config"]["keyMaterial"]["aesKey"].is_null()) {
+ aes_key_blob = request["config"]["keyMaterial"]["aesKey"];
+ }
+
+ // Validate that only one key type is provided
+ int key_count = 0;
+ if (!kms_key_id.empty()) key_count++;
+ if (!rsa_key_blob.empty()) key_count++;
+ if (!aes_key_blob.empty()) key_count++;
+
+ if (key_count != 1) {
+ return send_response(connection, 400,
+ "{\"error\":\"KeyMaterial must contain exactly one non-null key type\"}");
+ }
+
+ // RSA is not supported by C++ SDK
+ if (!rsa_key_blob.empty()) {
+ return send_response(connection, 501,
+ "{\"error\":\"RSA key wrapping is not supported in C++ S3 Encryption Client\"}");
+ }
+
+ bool legacy1 = request["config"]["enableLegacyWrappingAlgorithms"];
+ bool legacy2 = request["config"]["enableLegacyUnauthenticatedModes"];
+ bool inst_put = false;
+ if (request["config"].contains("instructionFileConfig") &&
+ request["config"]["instructionFileConfig"].contains("enableInstructionFilePutObject")) {
+ inst_put = request["config"]["instructionFileConfig"]["enableInstructionFilePutObject"];
+ }
+
+ // Create CryptoConfigurationV2 based on key type
+ std::shared_ptr config;
+
+ if (!aes_key_blob.empty()) {
+ // Base64 decode the AES key
+ Aws::Utils::ByteBuffer decoded = Aws::Utils::HashingUtils::Base64Decode(aes_key_blob);
+ if (decoded.GetLength() == 0) {
+ return send_response(connection, 400,
+ "{\"error\":\"Failed to decode AES key\"}");
+ }
+
+ Aws::Utils::CryptoBuffer key_buffer(
+ decoded.GetUnderlyingData(),
+ decoded.GetLength()
+ );
+
+ auto materials = std::make_shared<
+ Aws::S3Encryption::Materials::SimpleEncryptionMaterialsWithGCMAAD>(
+ key_buffer
+ );
+ config = std::make_shared(materials);
+ } else if (!kms_key_id.empty()) {
+ auto materials = std::make_shared(kms_key_id);
+ config = std::make_shared(materials);
+ } else {
+ return send_response(connection, 400,
+ "{\"error\":\"No valid key material provided\"}");
+ }
+
+ // Apply common configuration settings (applies to both AES and KMS)
+ if (legacy1 || legacy2)
+ config->SetSecurityProfile(SecurityProfile::V2_AND_LEGACY);
+ if (legacy2)
+ config->SetUnAuthenticatedRangeGet(RangeGetMode::ALL);
+ if (inst_put)
+ config->SetStorageMethod(StorageMethod::INSTRUCTION_FILE);
+
+ // Create S3EncryptionClientV2 with standard configuration
+ Aws::Client::ClientConfiguration clientConfig;
+ clientConfig.maxConnections = 512; // Large pool per client
+ clientConfig.retryStrategy = Aws::Client::InitRetryStrategy("standard");
+ auto encryption_client = std::make_shared(*config, clientConfig);
+
+ std::string client_id = generate_uuid();
+ set_client(client_id, encryption_client);
+
+ json response = {{"clientId", client_id}};
+ return send_response(connection, 200, response.dump());
+ } catch (const std::exception &e) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] handle_create_client exception: %s\n", e.what());
+ return send_response(connection, 500,
+ "{\"error\":\"An exception was thrown.\"}");
+ } catch (...) {
+ return send_response(connection, 500, "{\"error\":\"Unknown error\"}");
+ }
+}
+
+void fill_context(Aws::Map &map,
+ const std::string &metadata) {
+ if (metadata.empty()) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] fill_context: metadata is empty\n");
+ return;
+ }
+
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] fill_context: raw metadata='%s' (length=%zu)\n",
+ metadata.c_str(), metadata.length());
+
+ // Parse metadata format: [key1]:[value1],[key2]:[value2],...
+ // or single pair: [key]:[value]
+ std::string current = metadata;
+ size_t pos = 0;
+ int pair_count = 0;
+
+ while (pos < current.length()) {
+ // Find opening bracket for key
+ size_t key_start = current.find('[', pos);
+ if (key_start == std::string::npos)
+ break;
+
+ // Find closing bracket for key
+ size_t key_end = current.find(']', key_start);
+ if (key_end == std::string::npos)
+ break;
+
+ // Find colon separator
+ size_t colon = current.find(':', key_end);
+ if (colon == std::string::npos)
+ break;
+
+ // Find opening bracket for value
+ size_t value_start = current.find('[', colon);
+ if (value_start == std::string::npos)
+ break;
+
+ // Find closing bracket for value
+ size_t value_end = current.find(']', value_start);
+ if (value_end == std::string::npos)
+ break;
+
+ // Extract key and value
+ std::string key = current.substr(key_start + 1, key_end - key_start - 1);
+ std::string value =
+ current.substr(value_start + 1, value_end - value_start - 1);
+
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] fill_context: parsed pair #%d: key='%s', value='%s'\n",
+ ++pair_count, key.c_str(), value.c_str());
+
+ // Add to map
+ map.emplace(key, value);
+
+ // Move to next pair (look for comma or next opening bracket)
+ pos = value_end + 1;
+ size_t comma = current.find(',', pos);
+ if (comma != std::string::npos) {
+ pos = comma + 1;
+ }
+ }
+
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] fill_context: completed, parsed %d pairs into map\n", pair_count);
+}
+
+MHD_Result handle_get_object(struct MHD_Connection *connection,
+ std::string bucket,
+ std::string key,
+ std::string client_id,
+ std::string metadata,
+ std::string range) {
+ // Get thread ID for debugging concurrent operations
+ std::thread::id thread_id = std::this_thread::get_id();
+
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject START: thread=%lu, bucket=%s, key=%s, client_id=%s, metadata_length=%zu, range=%s\n",
+ (unsigned long)std::hash{}(thread_id), bucket.c_str(), key.c_str(), client_id.c_str(), metadata.length(), range.c_str());
+
+ auto client = get_client(client_id);
+ if (!client) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] GetObject error: Client not found for client_id=%s\n", client_id.c_str());
+ return send_response(connection, 404, "{\"error\":\"Client not found\"}");
+ }
+
+ try {
+ Aws::S3::Model::GetObjectRequest request;
+ request.SetBucket(bucket);
+ request.SetKey(key);
+
+ // Add range header if provided
+ if (!range.empty()) {
+ request.SetRange(range);
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject: Setting range=%s\n", range.c_str());
+ }
+
+ Aws::Map kmsContextMap;
+ fill_context(kmsContextMap, metadata);
+
+ // Log the encryption context map size and contents
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject: encryption context map size=%zu\n", kmsContextMap.size());
+ for (const auto& pair : kmsContextMap) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject: context['%s']='%s'\n",
+ pair.first.c_str(), pair.second.c_str());
+ }
+
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject: calling client->GetObject() for key=%s\n", key.c_str());
+
+ // Keep outcome alive to ensure stream remains valid
+ auto outcome = client->GetObject(request, kmsContextMap);
+
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject: client->GetObject() returned for key=%s\n", key.c_str());
+
+ if (outcome.IsSuccess()) {
+ // Read the stream completely before outcome goes out of scope
+ auto &stream = outcome.GetResult().GetBody();
+ std::stringstream buffer;
+ buffer << stream.rdbuf();
+ std::string content = buffer.str();
+
+ // Validate we read something
+ if (content.empty() && stream.fail()) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] GetObject error: Failed to read stream for bucket=%s, key=%s\n",
+ bucket.c_str(), key.c_str());
+ auto msg = make_error("Failed to read response stream", 500);
+ return send_response(connection, 500, msg);
+ }
+
+ fprintf(stderr, "[CPP-V2-TRANSITION] GetObject success: bucket=%s, key=%s, size=%zu bytes\n",
+ bucket.c_str(), key.c_str(), content.length());
+
+ // Create and send response
+ struct MHD_Response *response = MHD_create_response_from_buffer(
+ content.length(), (void *)content.data(), MHD_RESPMEM_MUST_COPY);
+
+ // Add keep-alive header
+ MHD_add_response_header(response, "Connection", "keep-alive");
+ MHD_add_response_header(response, "Keep-Alive", "timeout=30, max=100");
+
+ MHD_Result ret = MHD_queue_response(connection, 200, response);
+ MHD_destroy_response(response);
+
+ return ret;
+ } else {
+ // Enhanced error logging with thread info
+ auto error = outcome.GetError();
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject FAILED: thread=%lu, key=%s\n",
+ (unsigned long)std::hash{}(thread_id), key.c_str());
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject error details:\n");
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] - Message: %s\n", error.GetMessage().c_str());
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] - ExceptionName: %s\n", error.GetExceptionName().c_str());
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] - ResponseCode: %d\n", (int)error.GetResponseCode());
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] - ShouldRetry: %s\n", error.ShouldRetry() ? "true" : "false");
+
+ auto msg = make_error(outcome.GetError().GetMessage(), 500);
+ fprintf(stderr, "[CPP-V2-TRANSITION] GetObject AWS error: %s\n", msg.c_str());
+ return send_response(connection, 500, msg);
+ }
+ } catch (const std::exception &e) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject EXCEPTION: thread=%lu, key=%s, what=%s\n",
+ (unsigned long)std::hash{}(thread_id), key.c_str(), e.what());
+ auto msg = make_error(e.what(), 500);
+ return send_response(connection, 500, msg);
+ } catch (...) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] [DEBUG] GetObject UNKNOWN EXCEPTION: thread=%lu, key=%s\n",
+ (unsigned long)std::hash{}(thread_id), key.c_str());
+ auto msg = make_error("Unknown error in GetObject", 500);
+ return send_response(connection, 500, msg);
+ }
+}
+
+MHD_Result handle_put_object(struct MHD_Connection *connection,
+ std::string bucket,
+ std::string key,
+ std::string client_id,
+ std::string body,
+ std::string metadata) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] PutObject request: bucket=%s, key=%s, client_id=%s, body_size=%zu\n",
+ bucket.c_str(), key.c_str(), client_id.c_str(), body.length());
+
+ auto client = get_client(client_id);
+ if (!client) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] PutObject error: Client not found for client_id=%s\n", client_id.c_str());
+ return send_response(connection, 404, "{\"error\":\"Client not found\"}");
+ }
+
+ try {
+ // Create owned copy of body data to ensure it lives through the S3 operation
+ auto body_ptr = std::make_shared(body);
+
+ Aws::Map kmsContextMap;
+ fill_context(kmsContextMap, metadata);
+
+ Aws::S3::Model::PutObjectRequest request;
+ request.SetBucket(bucket);
+ request.SetKey(key);
+
+ // Create stream from owned body data
+ auto stream = std::make_shared(*body_ptr);
+ request.SetBody(stream);
+
+ // Synchronous call - waits for S3 operation to complete
+ // body_ptr keeps the data alive through this entire operation
+ auto outcome = client->PutObject(request, kmsContextMap);
+ if (outcome.IsSuccess()) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] PutObject success: bucket=%s, key=%s\n", bucket.c_str(), key.c_str());
+ json response = {{"bucket", bucket}, {"key", key}};
+ return send_response(connection, 200, response.dump());
+ } else {
+ auto msg = make_error(outcome.GetError().GetMessage(), 500);
+ fprintf(stderr, "[CPP-V2-TRANSITION] PutObject AWS error: %s\n", msg.c_str());
+ return send_response(connection, 500, msg);
+ }
+ } catch (const std::exception &e) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] PutObject exception: %s\n", e.what());
+ auto msg = make_error(e.what(), 500);
+ return send_response(connection, 500, msg);
+ }
+}
+
+void request_completed(void *cls, struct MHD_Connection *connection,
+ void **con_cls, enum MHD_RequestTerminationCode toe) {
+ // Clean up the request-specific context when request is truly complete
+ // This is called AFTER all handlers have returned and the response has been sent
+
+ // Log why the request was terminated
+ const char* reason = "UNKNOWN";
+ switch (toe) {
+ case MHD_REQUEST_TERMINATED_COMPLETED_OK:
+ reason = "COMPLETED_OK";
+ break;
+ case MHD_REQUEST_TERMINATED_WITH_ERROR:
+ reason = "WITH_ERROR";
+ break;
+ case MHD_REQUEST_TERMINATED_TIMEOUT_REACHED:
+ reason = "TIMEOUT_REACHED";
+ break;
+ case MHD_REQUEST_TERMINATED_DAEMON_SHUTDOWN:
+ reason = "DAEMON_SHUTDOWN";
+ break;
+ case MHD_REQUEST_TERMINATED_READ_ERROR:
+ reason = "READ_ERROR";
+ break;
+ case MHD_REQUEST_TERMINATED_CLIENT_ABORT:
+ reason = "CLIENT_ABORT";
+ break;
+ }
+ fprintf(stderr, "[CPP-V2-TRANSITION] request_completed called, reason=%s, con_cls=%p\n",
+ reason, *con_cls);
+
+ if (*con_cls != nullptr) {
+ std::string *body = static_cast(*con_cls);
+ delete body; // Safe to delete now - all synchronous operations are complete
+ *con_cls = nullptr;
+ }
+}
+
+MHD_Result request_handler(void *cls, struct MHD_Connection *connection,
+ const char *url, const char *method,
+ const char *version, const char *upload_data,
+ size_t *upload_data_size, void **con_cls) {
+ try {
+ std::string method_str(method);
+ std::string url_str(url);
+ bool is_push = method_str == "POST" || method_str == "PUT";
+
+ // LOG: Every request entry (even first-time calls)
+ if (*con_cls == nullptr) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] REQUEST START: method=%s, url=%s, version=%s, con_cls=NULL, upload_data_size=%zu\n",
+ method, url, version, *upload_data_size);
+ }
+
+ // Initialize request context on first call
+ if (*con_cls == nullptr) {
+ // Allocate unique state for each request to avoid race conditions
+ *con_cls = new std::string();
+ fprintf(stderr, "[CPP-V2-TRANSITION] REQUEST INIT: allocated new request context for %s %s\n", method, url);
+ return MHD_YES;
+ }
+
+ // LOG: Subsequent calls
+ if (is_push && *upload_data_size > 0) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] REQUEST DATA: %s %s receiving %zu bytes\n", method, url, *upload_data_size);
+ } else if (*upload_data_size == 0) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] REQUEST COMPLETE: %s %s ready for processing\n", method, url);
+ }
+
+ // Accumulate request body data for POST/PUT requests
+ if (is_push && *upload_data_size > 0) {
+ std::string *body = static_cast(*con_cls);
+ body->append(upload_data, *upload_data_size);
+ *upload_data_size = 0;
+ return MHD_YES;
+ }
+
+ // At this point, *upload_data_size == 0, meaning we have all the data
+ // Now we can safely process the request
+
+ // LOG: About to process request
+ fprintf(stderr, "[CPP-V2-TRANSITION] PROCESSING: %s %s\n", method, url);
+
+ // Handle client creation endpoint
+ if (is_push && url_str == "/client") {
+ fprintf(stderr, "[CPP-V2-TRANSITION] Handling /client endpoint\n");
+ std::string *body = static_cast(*con_cls);
+ MHD_Result result = handle_create_client(connection, *body);
+ fprintf(stderr, "[CPP-V2-TRANSITION] /client handler returned: %d\n", result);
+ return result;
+ }
+
+ // Handle object operations
+ if (url_str.find("/object/") == 0) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] Handling /object/ endpoint\n");
+ std::string path = url_str.substr(8); // Remove "/object/"
+ size_t slash_pos = path.find('/');
+ if (slash_pos != std::string::npos) {
+ std::string bucket = path.substr(0, slash_pos);
+ std::string key = path.substr(slash_pos + 1);
+ std::string client_id = get_header_value(connection, "clientid");
+ std::string metadata = get_header_value(connection, "content-metadata");
+
+ fprintf(stderr, "[CPP-V2-TRANSITION] Object operation: bucket=%s, key=%s, client_id=%s, method=%s\n",
+ bucket.c_str(), key.c_str(), client_id.c_str(), method);
+
+ if (method_str == "GET") {
+ fprintf(stderr, "[CPP-V2-TRANSITION] Dispatching to handle_get_object\n");
+ std::string range = get_header_value(connection, "Range");
+ MHD_Result result = handle_get_object(connection, bucket, key, client_id, metadata, range);
+ fprintf(stderr, "[CPP-V2-TRANSITION] handle_get_object returned: %d\n", result);
+ return result;
+ } else if (method_str == "PUT") {
+ fprintf(stderr, "[CPP-V2-TRANSITION] Dispatching to handle_put_object\n");
+ std::string *body = static_cast(*con_cls);
+ MHD_Result result = handle_put_object(connection, bucket, key, client_id, *body, metadata);
+ fprintf(stderr, "[CPP-V2-TRANSITION] handle_put_object returned: %d\n", result);
+ return result;
+ } else {
+ fprintf(stderr, "[CPP-V2-TRANSITION] Method not allowed: %s\n", method);
+ return send_response(connection, 405, "{\"error\":\"Method not allowed\"}");
+ }
+ }
+ }
+
+ // Return error for unrecognized endpoints
+ fprintf(stderr, "[CPP-V2-TRANSITION] ERROR: Unrecognized endpoint: %s %s\n", method, url);
+ return send_response(connection, 404,
+ "{\"error\":\"Not idea what is happening\"}");
+ } catch (const std::exception &e) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] FATAL: Unhandled exception in request_handler: %s (method=%s, url=%s)\n",
+ e.what(), method, url);
+ // Try to send error response, but connection might already be broken
+ try {
+ return send_response(connection, 500,
+ "{\"error\":\"Internal server error: unhandled exception\"}");
+ } catch (...) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] FATAL: Failed to send error response\n");
+ return MHD_NO;
+ }
+ } catch (...) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] FATAL: Unknown exception in request_handler (method=%s, url=%s)\n",
+ method, url);
+ // Try to send error response, but connection might already be broken
+ try {
+ return send_response(connection, 500,
+ "{\"error\":\"Internal server error: unknown exception\"}");
+ } catch (...) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] FATAL: Failed to send error response\n");
+ return MHD_NO;
+ }
+ }
+}
+
+// Error log callback for libmicrohttpd
+void log_mhd_error(void* cls, const char* fmt, va_list ap) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] [MHD-ERROR] ");
+ vfprintf(stderr, fmt, ap);
+ fprintf(stderr, "\n");
+}
+
+// Connection notification callback - called when a client connects
+MHD_Result notify_connection(void *cls,
+ struct MHD_Connection *connection,
+ void **socket_context,
+ enum MHD_ConnectionNotificationCode toe) {
+ if (toe == MHD_CONNECTION_NOTIFY_STARTED) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] [MHD-CONNECT] New connection started\n");
+ } else if (toe == MHD_CONNECTION_NOTIFY_CLOSED) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] [MHD-DISCONNECT] Connection closed\n");
+ }
+ return MHD_YES;
+}
+
+int main() {
+ Aws::SDKOptions options;
+
+ // Configure AWS SDK logging to output to stderr (which goes to server.log)
+ // Using Debug level to capture all SDK activity including CryptoModule errors
+ options.loggingOptions.logLevel = Aws::Utils::Logging::LogLevel::Debug;
+ options.loggingOptions.logger_create_fn = []() {
+ return std::make_shared(
+ Aws::Utils::Logging::LogLevel::Debug
+ );
+ };
+
+ fprintf(stderr, "[CONFIG] AWS SDK logging enabled at Debug level\n");
+
+ Aws::InitAPI(options);
+
+ // Detect CPU core count and configure threading
+ unsigned int num_cores = std::thread::hardware_concurrency();
+ if (num_cores == 0) {
+ num_cores = 4;
+ fprintf(stderr, "[CPP-V2-TRANSITION] [WARNING] CPU core detection failed, defaulting to %u cores\n", num_cores);
+ }
+
+ g_thread_pool_size = num_cores * 2;
+ unsigned int connection_limit = g_thread_pool_size;
+
+ // Log configuration
+ fprintf(stderr, "[CONFIG] Detected CPU cores: %u\n", num_cores);
+ fprintf(stderr, "[CONFIG] Thread pool size: %u\n", g_thread_pool_size);
+ fprintf(stderr, "[CONFIG] Connection limit: %u\n", connection_limit);
+ fprintf(stderr, "[CONFIG] Each S3 client will use 512 max connections\n");
+
+ int port = 8097;
+
+ struct MHD_Daemon *daemon =
+ MHD_start_daemon(MHD_USE_POLL_INTERNALLY | MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_ERROR_LOG,
+ port, NULL, NULL,
+ &request_handler, NULL,
+ MHD_OPTION_EXTERNAL_LOGGER, log_mhd_error, NULL,
+ MHD_OPTION_NOTIFY_CONNECTION, notify_connection, NULL,
+ MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
+ MHD_OPTION_THREAD_POOL_SIZE, g_thread_pool_size,
+ MHD_OPTION_CONNECTION_LIMIT, connection_limit,
+ MHD_OPTION_CONNECTION_TIMEOUT, 10,
+ MHD_OPTION_END);
+
+ if (!daemon) {
+ fprintf(stderr, "[CPP-V2-TRANSITION] Failed to start server on port %d\n", port);
+ Aws::ShutdownAPI(options);
+ return 1;
+ }
+
+ fprintf(stderr, "Server running on port %d\n", port);
+ sleep(10000);
+
+ MHD_stop_daemon(daemon);
+ Aws::ShutdownAPI(options);
+ fprintf(stderr, "Ending server\n");
+ return 0;
+}
diff --git a/test-server/cpp-v3-server/.duvet/.gitignore b/test-server/cpp-v3-server/.duvet/.gitignore
new file mode 100644
index 00000000..93956e36
--- /dev/null
+++ b/test-server/cpp-v3-server/.duvet/.gitignore
@@ -0,0 +1,3 @@
+reports/
+requirements/
+specification/
\ No newline at end of file
diff --git a/test-server/cpp-v3-server/.duvet/config.toml b/test-server/cpp-v3-server/.duvet/config.toml
new file mode 100644
index 00000000..3a49ac85
--- /dev/null
+++ b/test-server/cpp-v3-server/.duvet/config.toml
@@ -0,0 +1,45 @@
+'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json"
+
+[[source]]
+pattern = "aws-sdk-cpp/src/aws-cpp-sdk-s3-encryption/**/*.cpp"
+
+[[source]]
+pattern = "aws-sdk-cpp/src/aws-cpp-sdk-s3-encryption/**/*.h"
+
+[[source]]
+pattern = "aws-sdk-cpp/src/aws-cpp-sdk-core/include/aws/core/utils/crypto/*.h"
+
+[[source]]
+pattern = "aws-sdk-cpp/src/aws-cpp-sdk-core/include/aws/core/utils/crypto/*.cpp"
+
+[[source]]
+pattern = "aws-sdk-cpp/tests/aws-cpp-sdk-s3-encryption-tests/*.cpp"
+
+[[source]]
+pattern = "aws-sdk-cpp/tests/aws-cpp-sdk-s3-encryption-integration-tests/*.cpp"
+
+[[source]]
+pattern = "compliance.txt"
+
+[[specification]]
+source = "../specification/s3-encryption/client.md"
+[[specification]]
+source = "../specification/s3-encryption/data-format/content-metadata.md"
+[[specification]]
+source = "../specification/s3-encryption/data-format/metadata-strategy.md"
+[[specification]]
+source = "../specification/s3-encryption/encryption.md"
+[[specification]]
+source = "../specification/s3-encryption/decryption.md"
+[[specification]]
+source = "../specification/s3-encryption/key-derivation.md"
+[[specification]]
+source = "../specification/s3-encryption/key-commitment.md"
+
+
+[report.html]
+enabled = true
+
+# Enable snapshots to prevent requirement coverage regressions
+[report.snapshot]
+enabled = false
diff --git a/test-server/cpp-v3-server/CMakeLists.txt b/test-server/cpp-v3-server/CMakeLists.txt
new file mode 100644
index 00000000..0faac5f0
--- /dev/null
+++ b/test-server/cpp-v3-server/CMakeLists.txt
@@ -0,0 +1,49 @@
+cmake_minimum_required(VERSION 3.16)
+project(s3ec-cpp-v2-server)
+
+set(CMAKE_CXX_STANDARD 17)
+
+# Configure AWS SDK build options
+set(BUILD_ONLY "kms;s3;s3-encryption" CACHE STRING "Build only KMS, S3, and S3-encryption components")
+set(ENABLE_TESTING OFF CACHE BOOL "Disable testing")
+set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build static libraries")
+set(ENABLE_ADDRESS_SANITIZER ON CACHE BOOL "Enable Address Sanitizer")
+
+# Add AWS SDK as subdirectory
+add_subdirectory(aws-sdk-cpp)
+
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(LIBMICROHTTPD REQUIRED libmicrohttpd)
+
+find_package(nlohmann_json REQUIRED)
+
+add_executable(s3ec-server main.cpp)
+
+# Enable Address Sanitizer for the executable
+target_compile_options(s3ec-server PRIVATE -fsanitize=address -fno-omit-frame-pointer)
+target_link_options(s3ec-server PRIVATE -fsanitize=address)
+
+target_include_directories(s3ec-server PRIVATE
+ ${LIBMICROHTTPD_INCLUDE_DIRS}
+ /opt/homebrew/include
+)
+
+target_include_directories(s3ec-server PRIVATE
+ ${LIBMICROHTTPD_INCLUDE_DIRS}
+ /opt/homebrew/include
+)
+
+target_link_directories(s3ec-server PRIVATE
+ ${LIBMICROHTTPD_LIBRARY_DIRS}
+ /opt/homebrew/lib
+)
+
+target_link_libraries(s3ec-server
+ ${LIBMICROHTTPD_LIBRARIES}
+ aws-cpp-sdk-core
+ aws-cpp-sdk-kms
+ aws-cpp-sdk-s3
+ aws-cpp-sdk-s3-encryption
+ nlohmann_json::nlohmann_json
+ uuid
+)
diff --git a/test-server/cpp-v3-server/Makefile b/test-server/cpp-v3-server/Makefile
new file mode 100644
index 00000000..e90c8d73
--- /dev/null
+++ b/test-server/cpp-v3-server/Makefile
@@ -0,0 +1,43 @@
+# Makefile for S3 Encryption Client Testing
+
+.PHONY: build-server start-server stop-server wait-for-server
+
+PID_FILE := server.pid
+PORT := 8091
+
+build/s3ec-server:
+ mkdir -p build && cd build && cmake ..
+
+build-server: | build/s3ec-server
+ @echo "Building Cpp V3 server..."
+ cd build && $(MAKE)
+
+start-server:
+ @echo "Starting Cpp V3 server..."
+ cd build && \
+ AWS_ACCESS_KEY_ID="$$AWS_ACCESS_KEY_ID" \
+ AWS_SECRET_ACCESS_KEY="$$AWS_SECRET_ACCESS_KEY" \
+ AWS_SESSION_TOKEN="$$AWS_SESSION_TOKEN" \
+ AWS_REGION="us-west-2" \
+ ./s3ec-server > ../server.log 2>&1 & echo $$! > ../$(PID_FILE)
+ @echo "Cpp V3 server starting..."
+
+stop-server:
+ @echo "Stopping server on port $(PORT)..."
+ @lsof -ti:$(PORT) | xargs kill -9 2>/dev/null || true
+ @if [ -f $(PID_FILE) ]; then \
+ pkill -P $$(cat $(PID_FILE)) 2>/dev/null || true; \
+ kill -9 $$(cat $(PID_FILE)) 2>/dev/null || true; \
+ rm -f $(PID_FILE); \
+ fi
+ @rm -f server.log
+ @echo "Server stopped"
+
+wait-for-server:
+ $(MAKE) -C .. wait-for-port PORT=$(PORT)
+
+duvet:
+ duvet report
+
+view-report-mac:
+ open .duvet/reports/report.html
diff --git a/test-server/cpp-v3-server/README.md b/test-server/cpp-v3-server/README.md
new file mode 100644
index 00000000..8e77feda
--- /dev/null
+++ b/test-server/cpp-v3-server/README.md
@@ -0,0 +1,37 @@
+# C++ S3 Encryption Test Server
+
+Minimal C++ implementation of the S3 Encryption test server.
+
+## Dependencies
+
+- libmicrohttpd
+- AWS SDK for C++
+- nlohmann/json
+- uuid
+
+On MacOS you can
+```bash
+brew install libmicrohttpd nlohmann-json ossp-uuid
+```
+
+## Build
+
+```bash
+mkdir build && cd build
+cmake ..
+make
+```
+
+## Run
+
+```bash
+./s3ec-server
+```
+
+Server runs on localhost:8085
+
+## API Endpoints
+
+- `POST /client` - Create S3 encryption client
+- `GET /object/{bucket}/{key}` - Get encrypted object
+- `PUT /object/{bucket}/{key}` - Put encrypted object
\ No newline at end of file
diff --git a/test-server/cpp-v3-server/aws-sdk-cpp b/test-server/cpp-v3-server/aws-sdk-cpp
new file mode 160000
index 00000000..9110b0ff
--- /dev/null
+++ b/test-server/cpp-v3-server/aws-sdk-cpp
@@ -0,0 +1 @@
+Subproject commit 9110b0ff85094134a4f78316485fd7fe754a2a9c
diff --git a/test-server/cpp-v3-server/compliance.txt b/test-server/cpp-v3-server/compliance.txt
new file mode 100644
index 00000000..8225d8a9
--- /dev/null
+++ b/test-server/cpp-v3-server/compliance.txt
@@ -0,0 +1,119 @@
+** The C++ S3EC does not support re-encryption, nor custom instruction file suffixes
+//= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+//= type=exception
+//# The S3EC MAY support re-encryption/key rotation via Instruction Files.
+
+//= ../specification/s3-encryption/data-format/metadata-strategy.md#instruction-file
+//= type=exception
+//# The S3EC SHOULD support providing a custom Instruction File suffix on GetObject requests, regardless of whether or not re-encryption is supported.
+
+** We're not doing double encoding yet
+//= ../specification/s3-encryption/data-format/metadata-strategy.md#object-metadata
+//= type=exception
+//# The S3EC SHOULD support decoding the S3 Server's "double encoding".
+
+//= ../specification/s3-encryption/data-format/content-metadata.md#v3-only
+//= type=exception
+//# This material description string MAY be encoded by the esoteric double-encoding scheme used by the S3 web server.
+
+//= ../specification/s3-encryption/data-format/content-metadata.md#v3-only
+//= type=exception
+//# This encryption context string MAY be encoded by the esoteric double-encoding scheme used by the S3 web server.
+
+//= ../specification/s3-encryption/data-format/content-metadata.md#v1-v2-shared
+//= type=exception
+//# This string MAY be encoded by the esoteric double-encoding scheme used by the S3 web server.
+
+
+** The C++ S3EC does not support key rings nor cmms
+//= ../specification/s3-encryption/client.md#cryptographic-materials
+//= type=exception
+//# The S3EC MUST accept either one CMM or one Keyring instance upon initialization.
+//# If both a CMM and a Keyring are provided, the S3EC MUST throw an exception.
+//# When a Keyring is provided, the S3EC MUST create an instance of the DefaultCMM using the provided Keyring.
+
+
+** The C++ S3EC does not support Delayed Authentication buffer size configuration
+//= ../specification/s3-encryption/client.md#set-buffer-size
+//= type=exception
+//# The S3EC SHOULD accept a configurable buffer size which refers to the maximum ciphertext length in bytes to store in memory when Delayed Authentication mode is disabled.
+//# If Delayed Authentication mode is enabled, and the buffer size has been set to a value other than its default, the S3EC MUST throw an exception.
+//# If Delayed Authentication mode is disabled, and no buffer size is provided, the S3EC MUST set the buffer size to a reasonable default.
+
+
+** In the C++ S3EC, there is no connection between the S3 client and any potential KMS clients
+//= ../specification/s3-encryption/client.md#inherited-sdk-configuration
+//= type=exception
+//# If the S3EC accepts SDK client configuration, the configuration MUST be applied to all wrapped SDK clients including the KMS client.
+
+
+** In the C++ S3EC, the encryption algorithm is uniquely determined by the client version and the CommitmentPolicy
+
+//= ../specification/s3-encryption/client.md#encryption-algorithm
+//= type=exception
+//# The S3EC MUST support configuration of the encryption algorithm (or algorithm suite) during its initialization.
+//# The S3EC MUST validate that the configured encryption algorithm is not legacy.
+//# If the configured encryption algorithm is legacy, then the S3EC MUST throw an exception.
+
+//= ../specification/s3-encryption/client.md#key-commitment
+//= type=exception
+//# The S3EC MUST validate the configured Encryption Algorithm against the provided key commitment policy.
+//# If the configured Encryption Algorithm is incompatible with the key commitment policy, then it MUST throw an exception.
+
+
+** The C++ S3EC does not accept a source of randomness during client initialization
+//= ../specification/s3-encryption/client.md#randomness
+//= type=exception
+//# The S3EC MAY accept a source of randomness during client initialization.
+
+
+** This is silly, and I don't want to do it
+//= ../specification/s3-encryption/encryption.md#cipher-initialization
+//= type=exception
+//# The client SHOULD validate that the generated IV or Message ID is not zeros.
+
+** The C++ S3EC does not support custom materials.
+** The built in Raw Keyring always has an empty Materials Description
+** Therefore "x-amz-m" will never be written.
+//= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys
+//= type=exception
+//# - The mapkey "x-amz-m" SHOULD be present for V3 format objects that use Raw Keyring Material Description.
+
+
+** The C++ S3EC only implements GetObject and PutObject **
+
+//= ../specification/s3-encryption/client.md#aws-sdk-compatibility
+//= type=exception
+//# The S3EC MUST adhere to the same interface for API operations as the conventional AWS SDK S3 client.
+
+//= ../specification/s3-encryption/client.md#aws-sdk-compatibility
+//= type=exception
+//# The S3EC SHOULD support invoking operations unrelated to client-side encryption e.g.
+
+//= ../specification/s3-encryption/client.md#required-api-operations
+//= type=exception
+//# - DeleteObject MUST be implemented by the S3EC.
+//# - DeleteObject MUST delete the given object key.
+//# - DeleteObject MUST delete the associated instruction file using the default instruction file suffix.
+//# - DeleteObjects MUST be implemented by the S3EC.
+//# - DeleteObjects MUST delete each of the given objects.
+//# - DeleteObjects MUST delete each of the corresponding instruction files using the default instruction file suffix.
+
+//= ../specification/s3-encryption/client.md#optional-api-operations
+//= type=exception
+//# - CreateMultipartUpload MAY be implemented by the S3EC.
+//# - If implemented, CreateMultipartUpload MUST initiate a multipart upload.
+//# - UploadPart MAY be implemented by the S3EC.
+//# - UploadPart MUST encrypt each part.
+//# - Each part MUST be encrypted in sequence.
+//# - Each part MUST be encrypted using the same cipher instance for each part.
+//# - CompleteMultipartUpload MAY be implemented by the S3EC.
+//# - CompleteMultipartUpload MUST complete the multipart upload.
+//# - AbortMultipartUpload MAY be implemented by the S3EC.
+//# - AbortMultipartUpload MUST abort the multipart upload.
+
+//= ../specification/s3-encryption/client.md#optional-api-operations
+//= type=exception
+//# - ReEncryptInstructionFile MAY be implemented by the S3EC.
+//# - ReEncryptInstructionFile MUST decrypt the instruction file's encrypted data key for the given object using the client's CMM.
+//# - ReEncryptInstructionFile MUST re-encrypt the plaintext data key with a provided keyring.
diff --git a/test-server/cpp-v3-server/main.cpp b/test-server/cpp-v3-server/main.cpp
new file mode 100644
index 00000000..169fa517
--- /dev/null
+++ b/test-server/cpp-v3-server/main.cpp
@@ -0,0 +1,776 @@
+/*
+ * S3 Encryption Test Server - C++ V3
+ *
+ * CONCURRENCY AND SYNCHRONIZATION DESIGN:
+ *
+ * 1. Threading Model:
+ * - Uses MHD_USE_POLL_INTERNALLY with fixed thread pool
+ * - Thread pool size = CPU cores * 2 (auto-detected at startup)
+ * - Threads are reused across connections for efficiency
+ * - I/O multiplexing (poll) distributes connections across thread pool
+ * - All S3 operations are SYNCHRONOUS - server waits for S3 completion before responding
+ * - POLL mechanism avoids FD_SETSIZE=1024 limitation of select()
+ *
+ * 2. Resource Scaling:
+ * - All limits automatically scale with detected CPU count:
+ * * Thread pool size = num_cores * 2
+ * * Connection limit = num_cores * 2
+ * * S3 client maxConnections = num_cores * 2
+ * - Multiplier of 2 accounts for I/O blocking without starving throughput
+ * - Ensures optimal resource usage on any hardware configuration
+ *
+ * 3. Client Cache (client_cache_secret):
+ * - Protected by std::shared_mutex for efficient concurrent access
+ * - get_client() uses shared_lock (multiple threads can read simultaneously)
+ * - set_client() uses unique_lock (exclusive write access)
+ * - This allows concurrent GET/PUT operations without serialization
+ * - UUID-based keys guarantee uniqueness (always insert, never update)
+ *
+ * 4. Memory Management:
+ * - Request body allocated in request_handler (*con_cls = new std::string())
+ * - Body lifetime managed by libmicrohttpd - valid until request_completed()
+ * - All handler functions complete synchronously before returning
+ * - request_completed() safely deletes body after response sent
+ * - No memory leaks under sustained concurrent load
+ *
+ * 5. Synchronous Operation Guarantees:
+ * - GetObject: Waits for S3, reads full response stream, then returns
+ * - PutObject: Waits for S3 operation to complete, then returns
+ * - No async callbacks or background operations
+ * - Client receives response only after S3 operation completes
+ */
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+using json = nlohmann::json;
+using namespace Aws::S3Encryption;
+using Aws::S3Encryption::Materials::KMSWithContextEncryptionMaterials;
+
+// LRU cache for S3 encryption clients
+// Limits memory and connection pool growth by evicting least recently used clients
+const size_t MAX_CACHED_CLIENTS = 100; // Reasonable limit for concurrent test operations
+
+struct ClientCacheEntry {
+ std::shared_ptr client;
+ std::list::iterator lru_iter;
+};
+
+std::unordered_map client_cache_secret;
+std::list lru_order; // Most recently used at front
+std::shared_timed_mutex client_mutex; // Using shared_timed_mutex (C++14 compatible) for concurrent reads
+
+// Threading configuration - set at startup based on CPU cores
+unsigned int g_thread_pool_size = 8; // Default, will be overwritten in main()
+
+std::string generate_uuid() {
+ uuid_t uuid;
+ uuid_generate(uuid);
+ char uuid_str[37];
+ uuid_unparse(uuid, uuid_str);
+ return std::string(uuid_str);
+}
+
+std::shared_ptr get_client(const std::string &client_id)
+{
+ // Need unique_lock to update LRU order even on reads
+ std::unique_lock lock(client_mutex);
+ auto it = client_cache_secret.find(client_id);
+ if (it == client_cache_secret.end()) {
+ return std::shared_ptr();
+ } else {
+ // Move to front of LRU list (mark as most recently used)
+ lru_order.erase(it->second.lru_iter);
+ lru_order.push_front(client_id);
+ it->second.lru_iter = lru_order.begin();
+
+ return it->second.client;
+ }
+}
+
+void set_client(const std::string &client_id, std::shared_ptr client)
+{
+ // UUID guarantees unique keys - always insert, never update
+ // Still need exclusive lock because std::unordered_map isn't thread-safe for concurrent inserts
+ std::unique_lock lock(client_mutex);
+
+ // Add to front of LRU list (most recently used)
+ lru_order.push_front(client_id);
+
+ ClientCacheEntry entry;
+ entry.client = client;
+ entry.lru_iter = lru_order.begin();
+
+ client_cache_secret.emplace(client_id, entry);
+
+ // Evict least recently used clients if we exceed the limit
+ while (client_cache_secret.size() > MAX_CACHED_CLIENTS) {
+ std::string lru_client_id = lru_order.back();
+ lru_order.pop_back();
+
+ auto evict_it = client_cache_secret.find(lru_client_id);
+ if (evict_it != client_cache_secret.end()) {
+ fprintf(stderr, "[CPP-V3] [CACHE-EVICT] Evicting client %s (cache size was %zu)\n",
+ lru_client_id.c_str(), client_cache_secret.size());
+ client_cache_secret.erase(evict_it);
+ }
+ }
+
+ fprintf(stderr, "[CPP-V3] [CACHE-ADD] Added client %s (cache size now %zu)\n",
+ client_id.c_str(), client_cache_secret.size());
+}
+
+std::string get_header_value(struct MHD_Connection *connection,
+ const char *key) {
+ const char *value =
+ MHD_lookup_connection_value(connection, MHD_HEADER_KIND, key);
+ return value ? std::string(value) : "";
+}
+
+MHD_Result send_response(struct MHD_Connection *connection, int status_code,
+ const std::string &content) {
+ struct MHD_Response *response = MHD_create_response_from_buffer(
+ content.length(), (void *)content.data(), MHD_RESPMEM_MUST_COPY);
+ MHD_Result ret = MHD_queue_response(connection, status_code, response);
+ MHD_destroy_response(response);
+ return ret;
+}
+
+std::string make_error(const std::string &message, int status_code) {
+ return "{\"__type\": "
+ "\"software.amazon.encryption.s3#S3EncryptionClientError\", "
+ "\"message\": \"" +
+ message + "\"}";
+}
+
+MHD_Result unsupported(struct MHD_Connection *connection, std::string & commitmentPolicy, std::string & encryptionAlgorithm) {
+ fprintf(stderr, "Unsupported %s %s\n",commitmentPolicy.c_str(), encryptionAlgorithm.c_str() );
+ send_response(connection, 404, "{\"error\":\"Unsupported Option.\"}");
+ return MHD_YES;
+}
+
+std::string get_config(json & request, const char * x)
+{
+ if (!request.contains("config")) return "";
+ auto config = request["config"];
+ if (config.contains(x))
+ return config[x];
+ return "";
+}
+
+MHD_Result handle_create_client(struct MHD_Connection *connection,
+ const std::string &body) {
+ // Body is kept alive by *con_cls until request_completed fires, so it's safe to use directly
+ // All operations here are synchronous and complete before returning to caller
+
+ try {
+ json request = json::parse(body);
+
+ // Extract all key material types
+ std::string kms_key_id;
+ std::string rsa_key_blob;
+ std::string aes_key_blob;
+
+ if (request["config"]["keyMaterial"].contains("kmsKeyId") &&
+ !request["config"]["keyMaterial"]["kmsKeyId"].is_null()) {
+ kms_key_id = request["config"]["keyMaterial"]["kmsKeyId"];
+ }
+ if (request["config"]["keyMaterial"].contains("rsaKey") &&
+ !request["config"]["keyMaterial"]["rsaKey"].is_null()) {
+ rsa_key_blob = request["config"]["keyMaterial"]["rsaKey"];
+ }
+ if (request["config"]["keyMaterial"].contains("aesKey") &&
+ !request["config"]["keyMaterial"]["aesKey"].is_null()) {
+ aes_key_blob = request["config"]["keyMaterial"]["aesKey"];
+ }
+
+ // Validate that only one key type is provided
+ int key_count = 0;
+ if (!kms_key_id.empty()) key_count++;
+ if (!rsa_key_blob.empty()) key_count++;
+ if (!aes_key_blob.empty()) key_count++;
+
+ if (key_count != 1) {
+ return send_response(connection, 400,
+ "{\"error\":\"KeyMaterial must contain exactly one non-null key type\"}");
+ }
+
+ // RSA is not supported by C++ SDK
+ if (!rsa_key_blob.empty()) {
+ return send_response(connection, 501,
+ "{\"error\":\"RSA key wrapping is not supported in C++ S3 Encryption Client\"}");
+ }
+
+ bool legacy1 = request["config"]["enableLegacyWrappingAlgorithms"];
+ bool legacy2 = request["config"]["enableLegacyUnauthenticatedModes"];
+ bool inst_put = false;
+ if (request["config"].contains("instructionFileConfig") &&
+ request["config"]["instructionFileConfig"].contains("enableInstructionFilePutObject")) {
+ inst_put = request["config"]["instructionFileConfig"]["enableInstructionFilePutObject"];
+ }
+
+ std::string commitmentPolicy = get_config(request, "commitmentPolicy");
+ std::string encryptionAlgorithm = get_config(request, "encryptionAlgorithm");
+
+ // Create CryptoConfigurationV3 based on key type
+ std::optional config;
+
+ if (!aes_key_blob.empty()) {
+ // Base64 decode the AES key
+ Aws::Utils::ByteBuffer decoded = Aws::Utils::HashingUtils::Base64Decode(aes_key_blob);
+ if (decoded.GetLength() == 0) {
+ return send_response(connection, 400,
+ "{\"error\":\"Failed to decode AES key\"}");
+ }
+
+ Aws::Utils::CryptoBuffer key_buffer(
+ decoded.GetUnderlyingData(),
+ decoded.GetLength()
+ );
+
+ auto materials = std::make_shared<
+ Aws::S3Encryption::Materials::SimpleEncryptionMaterialsWithGCMAAD>(
+ key_buffer
+ );
+ config.emplace(materials);
+ } else if (!kms_key_id.empty()) {
+ auto materials = std::make_shared(kms_key_id);
+ config.emplace(materials);
+ } else {
+ return send_response(connection, 400,
+ "{\"error\":\"No valid key material provided\"}");
+ }
+
+ // Apply common configuration settings (applies to both AES and KMS)
+ if (legacy1 || legacy2)
+ config->AllowLegacy();
+ if (legacy2)
+ config->SetUnAuthenticatedRangeGet(RangeGetMode::ALL);
+ if (inst_put)
+ config->SetStorageMethod(StorageMethod::INSTRUCTION_FILE);
+
+ // Configure commitment policy (applies to both AES and KMS)
+ if (commitmentPolicy == "REQUIRE_ENCRYPT_REQUIRE_DECRYPT") {
+ if (encryptionAlgorithm == "ALG_AES_256_GCM_IV12_TAG16_NO_KDF" ||
+ encryptionAlgorithm == "ALG_AES_256_CBC_IV16_NO_KDF") {
+ return unsupported(connection, commitmentPolicy, encryptionAlgorithm);
+ }
+ config->SetCommitmentPolicy(CommitmentPolicy::REQUIRE_ENCRYPT_REQUIRE_DECRYPT);
+ } else if (commitmentPolicy == "REQUIRE_ENCRYPT_ALLOW_DECRYPT") {
+ if (encryptionAlgorithm == "ALG_AES_256_GCM_IV12_TAG16_NO_KDF") {
+ return unsupported(connection, commitmentPolicy, encryptionAlgorithm);
+ }
+ config->SetCommitmentPolicy(CommitmentPolicy::REQUIRE_ENCRYPT_ALLOW_DECRYPT);
+ } else if (commitmentPolicy == "FORBID_ENCRYPT_ALLOW_DECRYPT") {
+ if (encryptionAlgorithm == "ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY") {
+ return unsupported(connection, commitmentPolicy, encryptionAlgorithm);
+ }
+ config->SetCommitmentPolicy(CommitmentPolicy::FORBID_ENCRYPT_ALLOW_DECRYPT);
+ }
+
+ // Create S3EncryptionClientV3 with standard configuration
+ Aws::Client::ClientConfiguration clientConfig;
+ clientConfig.maxConnections = 512; // Large pool per client
+ clientConfig.retryStrategy = Aws::Client::InitRetryStrategy("standard");
+
+ // Increase timeouts for CI environments where SSL handshakes can be slow
+ // Default connectTimeoutMs is 1000ms, which is too short for busy CI runners
+ clientConfig.connectTimeoutMs = 10000; // 10 seconds for SSL connection establishment
+ clientConfig.requestTimeoutMs = 30000; // 30 seconds for complete request/response
+
+ // Disable automatic checksum calculation for encrypted streams
+ // The ChecksumInterceptor cannot handle non-seekable SymmetricCryptoStream
+ // which causes intermittent "BadDigest: CRC64NVME you specified did not match" errors
+ // when the stream gets consumed during checksum calculation and can't be rewound
+ clientConfig.checksumConfig.requestChecksumCalculation =
+ Aws::Client::RequestChecksumCalculation::WHEN_REQUIRED;
+
+ auto encryption_client = std::make_shared(*config, clientConfig);
+
+ std::string client_id = generate_uuid();
+ set_client(client_id, encryption_client);
+
+ json response = {{"clientId", client_id}};
+ return send_response(connection, 200, response.dump());
+ } catch (const std::exception &e) {
+ fprintf(stderr, "handle_create_client exception %s\n", e.what());
+ return send_response(connection, 500,
+ "{\"error\":\"An exception was thrown.\"}");
+ } catch (...) {
+ return send_response(connection, 500, "{\"error\":\"Unknown error\"}");
+ }
+}
+
+void fill_context(Aws::Map &map,
+ const std::string &metadata) {
+ if (metadata.empty()) {
+ fprintf(stderr, "[CPP-V3] [DEBUG] fill_context: metadata is empty\n");
+ return;
+ }
+
+ fprintf(stderr, "[CPP-V3] [DEBUG] fill_context: raw metadata='%s' (length=%zu)\n",
+ metadata.c_str(), metadata.length());
+
+ // Parse metadata format: [key1]:[value1],[key2]:[value2],...
+ // or single pair: [key]:[value]
+ std::string current = metadata;
+ size_t pos = 0;
+ int pair_count = 0;
+
+ while (pos < current.length()) {
+ // Find opening bracket for key
+ size_t key_start = current.find('[', pos);
+ if (key_start == std::string::npos)
+ break;
+
+ // Find closing bracket for key
+ size_t key_end = current.find(']', key_start);
+ if (key_end == std::string::npos)
+ break;
+
+ // Find colon separator
+ size_t colon = current.find(':', key_end);
+ if (colon == std::string::npos)
+ break;
+
+ // Find opening bracket for value
+ size_t value_start = current.find('[', colon);
+ if (value_start == std::string::npos)
+ break;
+
+ // Find closing bracket for value
+ size_t value_end = current.find(']', value_start);
+ if (value_end == std::string::npos)
+ break;
+
+ // Extract key and value
+ std::string key = current.substr(key_start + 1, key_end - key_start - 1);
+ std::string value =
+ current.substr(value_start + 1, value_end - value_start - 1);
+
+ fprintf(stderr, "[CPP-V3] [DEBUG] fill_context: parsed pair #%d: key='%s', value='%s'\n",
+ ++pair_count, key.c_str(), value.c_str());
+
+ // Add to map
+ map.emplace(key, value);
+
+ // Move to next pair (look for comma or next opening bracket)
+ pos = value_end + 1;
+ size_t comma = current.find(',', pos);
+ if (comma != std::string::npos) {
+ pos = comma + 1;
+ }
+ }
+
+ fprintf(stderr, "[CPP-V3] [DEBUG] fill_context: completed, parsed %d pairs into map\n", pair_count);
+}
+
+MHD_Result handle_get_object(struct MHD_Connection *connection,
+ std::string bucket,
+ std::string key,
+ std::string client_id,
+ std::string metadata,
+ std::string range) {
+ // Get thread ID for debugging concurrent operations
+ std::thread::id thread_id = std::this_thread::get_id();
+
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject START: thread=%lu, bucket=%s, key=%s, client_id=%s, metadata_length=%zu, range=%s\n",
+ (unsigned long)std::hash{}(thread_id), bucket.c_str(), key.c_str(), client_id.c_str(), metadata.length(), range.c_str());
+
+ auto client = get_client(client_id);
+ if (!client) {
+ fprintf(stderr, "[CPP-V3] GetObject error: Client not found for client_id=%s\n", client_id.c_str());
+ return send_response(connection, 404, "{\"error\":\"Client not found\"}");
+ }
+
+ try {
+ Aws::S3::Model::GetObjectRequest request;
+ request.SetBucket(bucket);
+ request.SetKey(key);
+
+ // Add range header if provided
+ if (!range.empty()) {
+ request.SetRange(range);
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject: Setting range=%s\n", range.c_str());
+ }
+
+ Aws::Map kmsContextMap;
+ fill_context(kmsContextMap, metadata);
+
+ // Log the encryption context map size and contents
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject: encryption context map size=%zu\n", kmsContextMap.size());
+ for (const auto& pair : kmsContextMap) {
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject: context['%s']='%s'\n",
+ pair.first.c_str(), pair.second.c_str());
+ }
+
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject: calling client->GetObject() for key=%s\n", key.c_str());
+
+ // Keep outcome alive to ensure stream remains valid
+ auto outcome = client->GetObject(request, kmsContextMap);
+
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject: client->GetObject() returned for key=%s\n", key.c_str());
+
+ if (outcome.IsSuccess()) {
+ // Read the stream completely before outcome goes out of scope
+ auto &stream = outcome.GetResult().GetBody();
+ std::stringstream buffer;
+ buffer << stream.rdbuf();
+ std::string content = buffer.str();
+
+ // Validate we read something
+ if (content.empty() && stream.fail()) {
+ fprintf(stderr, "[CPP-V3] GetObject error: Failed to read stream for bucket=%s, key=%s\n",
+ bucket.c_str(), key.c_str());
+ auto msg = make_error("Failed to read response stream", 500);
+ return send_response(connection, 500, msg);
+ }
+
+ fprintf(stderr, "[CPP-V3] GetObject success: bucket=%s, key=%s, size=%zu bytes\n",
+ bucket.c_str(), key.c_str(), content.length());
+
+ // Create and send response
+ struct MHD_Response *response = MHD_create_response_from_buffer(
+ content.length(), (void *)content.data(), MHD_RESPMEM_MUST_COPY);
+
+ // Add keep-alive header
+ MHD_add_response_header(response, "Connection", "keep-alive");
+ MHD_add_response_header(response, "Keep-Alive", "timeout=30, max=100");
+
+ MHD_Result ret = MHD_queue_response(connection, 200, response);
+ MHD_destroy_response(response);
+
+ return ret;
+ } else {
+ // Enhanced error logging with thread info
+ auto error = outcome.GetError();
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject FAILED: thread=%lu, key=%s\n",
+ (unsigned long)std::hash{}(thread_id), key.c_str());
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject error details:\n");
+ fprintf(stderr, "[CPP-V3] [DEBUG] - Message: %s\n", error.GetMessage().c_str());
+ fprintf(stderr, "[CPP-V3] [DEBUG] - ExceptionName: %s\n", error.GetExceptionName().c_str());
+ fprintf(stderr, "[CPP-V3] [DEBUG] - ResponseCode: %d\n", (int)error.GetResponseCode());
+ fprintf(stderr, "[CPP-V3] [DEBUG] - ShouldRetry: %s\n", error.ShouldRetry() ? "true" : "false");
+
+ auto msg = make_error(outcome.GetError().GetMessage(), 500);
+ fprintf(stderr, "[CPP-V3] GetObject AWS error: %s\n", msg.c_str());
+ return send_response(connection, 500, msg);
+ }
+ } catch (const std::exception &e) {
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject EXCEPTION: thread=%lu, key=%s, what=%s\n",
+ (unsigned long)std::hash{}(thread_id), key.c_str(), e.what());
+ auto msg = make_error(e.what(), 500);
+ return send_response(connection, 500, msg);
+ } catch (...) {
+ fprintf(stderr, "[CPP-V3] [DEBUG] GetObject UNKNOWN EXCEPTION: thread=%lu, key=%s\n",
+ (unsigned long)std::hash{}(thread_id), key.c_str());
+ auto msg = make_error("Unknown error in GetObject", 500);
+ return send_response(connection, 500, msg);
+ }
+}
+
+MHD_Result handle_put_object(struct MHD_Connection *connection,
+ std::string bucket,
+ std::string key,
+ std::string client_id,
+ std::string body,
+ std::string metadata) {
+ fprintf(stderr, "[CPP-V3] PutObject request: bucket=%s, key=%s, client_id=%s, body_size=%zu\n",
+ bucket.c_str(), key.c_str(), client_id.c_str(), body.length());
+
+ auto client = get_client(client_id);
+ if (!client) {
+ fprintf(stderr, "[CPP-V3] PutObject error: Client not found for client_id=%s\n", client_id.c_str());
+ return send_response(connection, 404, "{\"error\":\"Client not found\"}");
+ }
+
+ try {
+ // Create owned copy of body data to ensure it lives through the S3 operation
+ auto body_ptr = std::make_shared(body);
+
+ Aws::Map kmsContextMap;
+ fill_context(kmsContextMap, metadata);
+
+ Aws::S3::Model::PutObjectRequest request;
+ request.SetBucket(bucket);
+ request.SetKey(key);
+
+ // Create stream from owned body data
+ auto stream = std::make_shared(*body_ptr);
+ request.SetBody(stream);
+
+ // Synchronous call - waits for S3 operation to complete
+ // body_ptr keeps the data alive through this entire operation
+ auto outcome = client->PutObject(request, kmsContextMap);
+ if (outcome.IsSuccess()) {
+ fprintf(stderr, "[CPP-V3] PutObject success: bucket=%s, key=%s\n", bucket.c_str(), key.c_str());
+ json response = {{"bucket", bucket}, {"key", key}};
+ return send_response(connection, 200, response.dump());
+ } else {
+ auto msg = make_error(outcome.GetError().GetMessage(), 500);
+ fprintf(stderr, "[CPP-V3] PutObject AWS error: %s\n", msg.c_str());
+ return send_response(connection, 500, msg);
+ }
+ } catch (const std::exception &e) {
+ fprintf(stderr, "[CPP-V3] PutObject exception: %s\n", e.what());
+ auto msg = make_error(e.what(), 500);
+ return send_response(connection, 500, msg);
+ }
+}
+
+void request_completed(void *cls, struct MHD_Connection *connection,
+ void **con_cls, enum MHD_RequestTerminationCode toe) {
+ // Clean up the request-specific context when request is truly complete
+ // This is called AFTER all handlers have returned and the response has been sent
+
+ // Log why the request was terminated
+ const char* reason = "UNKNOWN";
+ switch (toe) {
+ case MHD_REQUEST_TERMINATED_COMPLETED_OK:
+ reason = "COMPLETED_OK";
+ break;
+ case MHD_REQUEST_TERMINATED_WITH_ERROR:
+ reason = "WITH_ERROR";
+ break;
+ case MHD_REQUEST_TERMINATED_TIMEOUT_REACHED:
+ reason = "TIMEOUT_REACHED";
+ break;
+ case MHD_REQUEST_TERMINATED_DAEMON_SHUTDOWN:
+ reason = "DAEMON_SHUTDOWN";
+ break;
+ case MHD_REQUEST_TERMINATED_READ_ERROR:
+ reason = "READ_ERROR";
+ break;
+ case MHD_REQUEST_TERMINATED_CLIENT_ABORT:
+ reason = "CLIENT_ABORT";
+ break;
+ }
+ fprintf(stderr, "[CPP-V3] request_completed called, reason=%s, con_cls=%p\n",
+ reason, *con_cls);
+
+ if (*con_cls != nullptr) {
+ std::string *body = static_cast(*con_cls);
+ delete body; // Safe to delete now - all synchronous operations are complete
+ *con_cls = nullptr;
+ }
+}
+
+MHD_Result request_handler(void *cls, struct MHD_Connection *connection,
+ const char *url, const char *method,
+ const char *version, const char *upload_data,
+ size_t *upload_data_size, void **con_cls) {
+ try {
+ std::string method_str(method);
+ std::string url_str(url);
+ bool is_push = method_str == "POST" || method_str == "PUT";
+
+ // LOG: Every request entry (even first-time calls)
+ if (*con_cls == nullptr) {
+ fprintf(stderr, "[CPP-V3] REQUEST START: method=%s, url=%s, version=%s, con_cls=NULL, upload_data_size=%zu\n",
+ method, url, version, *upload_data_size);
+ }
+
+ // Initialize request context on first call
+ if (*con_cls == nullptr) {
+ // Allocate unique state for each request to avoid race conditions
+ *con_cls = new std::string();
+ fprintf(stderr, "[CPP-V3] REQUEST INIT: allocated new request context for %s %s\n", method, url);
+ return MHD_YES;
+ }
+
+ // LOG: Subsequent calls
+ if (is_push && *upload_data_size > 0) {
+ fprintf(stderr, "[CPP-V3] REQUEST DATA: %s %s receiving %zu bytes\n", method, url, *upload_data_size);
+ } else if (*upload_data_size == 0) {
+ fprintf(stderr, "[CPP-V3] REQUEST COMPLETE: %s %s ready for processing\n", method, url);
+ }
+
+ // Accumulate request body data for POST/PUT requests
+ if (is_push && *upload_data_size > 0) {
+ std::string *body = static_cast(*con_cls);
+ body->append(upload_data, *upload_data_size);
+ *upload_data_size = 0;
+ return MHD_YES;
+ }
+
+ // At this point, *upload_data_size == 0, meaning we have all the data
+ // Now we can safely process the request
+
+ // LOG: About to process request
+ fprintf(stderr, "[CPP-V3] PROCESSING: %s %s\n", method, url);
+
+ // Handle client creation endpoint
+ if (is_push && url_str == "/client") {
+ fprintf(stderr, "[CPP-V3] Handling /client endpoint\n");
+ std::string *body = static_cast(*con_cls);
+ MHD_Result result = handle_create_client(connection, *body);
+ fprintf(stderr, "[CPP-V3] /client handler returned: %d\n", result);
+ return result;
+ }
+
+ // Handle object operations
+ if (url_str.find("/object/") == 0) {
+ fprintf(stderr, "[CPP-V3] Handling /object/ endpoint\n");
+ std::string path = url_str.substr(8); // Remove "/object/"
+ size_t slash_pos = path.find('/');
+ if (slash_pos != std::string::npos) {
+ std::string bucket = path.substr(0, slash_pos);
+ std::string key = path.substr(slash_pos + 1);
+ std::string client_id = get_header_value(connection, "clientid");
+ std::string metadata = get_header_value(connection, "content-metadata");
+
+ fprintf(stderr, "[CPP-V3] Object operation: bucket=%s, key=%s, client_id=%s, method=%s\n",
+ bucket.c_str(), key.c_str(), client_id.c_str(), method);
+
+ if (method_str == "GET") {
+ fprintf(stderr, "[CPP-V3] Dispatching to handle_get_object\n");
+ std::string range = get_header_value(connection, "Range");
+ MHD_Result result = handle_get_object(connection, bucket, key, client_id, metadata, range);
+ fprintf(stderr, "[CPP-V3] handle_get_object returned: %d\n", result);
+ return result;
+ } else if (method_str == "PUT") {
+ fprintf(stderr, "[CPP-V3] Dispatching to handle_put_object\n");
+ std::string *body = static_cast(*con_cls);
+ MHD_Result result = handle_put_object(connection, bucket, key, client_id, *body, metadata);
+ fprintf(stderr, "[CPP-V3] handle_put_object returned: %d\n", result);
+ return result;
+ } else {
+ fprintf(stderr, "[CPP-V3] Method not allowed: %s\n", method);
+ return send_response(connection, 405, "{\"error\":\"Method not allowed\"}");
+ }
+ }
+ }
+
+ // Return error for unrecognized endpoints
+ fprintf(stderr, "[CPP-V3] ERROR: Unrecognized endpoint: %s %s\n", method, url);
+ return send_response(connection, 404,
+ "{\"error\":\"Not idea what is happening\"}");
+ } catch (const std::exception &e) {
+ fprintf(stderr, "[CPP-V3] FATAL: Unhandled exception in request_handler: %s (method=%s, url=%s)\n",
+ e.what(), method, url);
+ // Try to send error response, but connection might already be broken
+ try {
+ return send_response(connection, 500,
+ "{\"error\":\"Internal server error: unhandled exception\"}");
+ } catch (...) {
+ fprintf(stderr, "[CPP-V3] FATAL: Failed to send error response\n");
+ return MHD_NO;
+ }
+ } catch (...) {
+ fprintf(stderr, "[CPP-V3] FATAL: Unknown exception in request_handler (method=%s, url=%s)\n",
+ method, url);
+ // Try to send error response, but connection might already be broken
+ try {
+ return send_response(connection, 500,
+ "{\"error\":\"Internal server error: unknown exception\"}");
+ } catch (...) {
+ fprintf(stderr, "[CPP-V3] FATAL: Failed to send error response\n");
+ return MHD_NO;
+ }
+ }
+}
+
+// Error log callback for libmicrohttpd
+void log_mhd_error(void* cls, const char* fmt, va_list ap) {
+ fprintf(stderr, "[CPP-V3] [MHD-ERROR] ");
+ vfprintf(stderr, fmt, ap);
+ fprintf(stderr, "\n");
+}
+
+// Connection notification callback - called when a client connects
+MHD_Result notify_connection(void *cls,
+ struct MHD_Connection *connection,
+ void **socket_context,
+ enum MHD_ConnectionNotificationCode toe) {
+ if (toe == MHD_CONNECTION_NOTIFY_STARTED) {
+ fprintf(stderr, "[CPP-V3] [MHD-CONNECT] New connection started\n");
+ } else if (toe == MHD_CONNECTION_NOTIFY_CLOSED) {
+ fprintf(stderr, "[CPP-V3] [MHD-DISCONNECT] Connection closed\n");
+ }
+ return MHD_YES;
+}
+
+int main() {
+ Aws::SDKOptions options;
+
+ // Configure AWS SDK logging to output to stderr (which goes to server.log)
+ // Using Debug level to capture all SDK activity including CryptoModule errors
+ options.loggingOptions.logLevel = Aws::Utils::Logging::LogLevel::Debug;
+ options.loggingOptions.logger_create_fn = []() {
+ return std::make_shared(
+ Aws::Utils::Logging::LogLevel::Debug
+ );
+ };
+
+ fprintf(stderr, "[CONFIG] AWS SDK logging enabled at Debug level\n");
+
+ Aws::InitAPI(options);
+
+ // Detect CPU core count and configure threading
+ unsigned int num_cores = std::thread::hardware_concurrency();
+ if (num_cores == 0) {
+ num_cores = 4; // Fallback if detection fails
+ fprintf(stderr, "[WARNING] CPU core detection failed, defaulting to %u cores\n", num_cores);
+ }
+
+ // Thread pool size = num_cores * 2 (allows for I/O blocking without starving throughput)
+ g_thread_pool_size = num_cores * 2;
+ unsigned int connection_limit = g_thread_pool_size;
+
+ // Log configuration
+ fprintf(stderr, "[CONFIG] Detected CPU cores: %u\n", num_cores);
+ fprintf(stderr, "[CONFIG] Thread pool size: %u\n", g_thread_pool_size);
+ fprintf(stderr, "[CONFIG] Connection limit: %u\n", connection_limit);
+ fprintf(stderr, "[CONFIG] Each S3 client will use 512 max connections\n");
+
+ int port = 8091;
+
+ struct MHD_Daemon *daemon =
+ MHD_start_daemon(MHD_USE_POLL_INTERNALLY | MHD_USE_INTERNAL_POLLING_THREAD | MHD_USE_ERROR_LOG,
+ port, NULL, NULL,
+ &request_handler, NULL,
+ MHD_OPTION_EXTERNAL_LOGGER, log_mhd_error, NULL,
+ MHD_OPTION_NOTIFY_CONNECTION, notify_connection, NULL,
+ MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
+ MHD_OPTION_THREAD_POOL_SIZE, g_thread_pool_size,
+ MHD_OPTION_CONNECTION_LIMIT, connection_limit,
+ MHD_OPTION_CONNECTION_TIMEOUT, 10,
+ MHD_OPTION_END);
+
+ if (!daemon) {
+ fprintf(stderr, "Failed to start server on port %d\n", port);
+ Aws::ShutdownAPI(options);
+ return 1;
+ }
+
+ fprintf(stderr, "Server running on port %d\n", port);
+ sleep(10000);
+
+ MHD_stop_daemon(daemon);
+ Aws::ShutdownAPI(options);
+ fprintf(stderr, "Ending server\n");
+ return 0;
+}
diff --git a/test-server/go-v3-transition-server/.duvet/.gitignore b/test-server/go-v3-transition-server/.duvet/.gitignore
new file mode 100644
index 00000000..32ad579b
--- /dev/null
+++ b/test-server/go-v3-transition-server/.duvet/.gitignore
@@ -0,0 +1,3 @@
+reports/
+requirements/
+specification/
diff --git a/test-server/go-v3-transition-server/.duvet/config.toml b/test-server/go-v3-transition-server/.duvet/config.toml
new file mode 100644
index 00000000..713e72d3
--- /dev/null
+++ b/test-server/go-v3-transition-server/.duvet/config.toml
@@ -0,0 +1,27 @@
+'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json"
+
+[[source]]
+pattern = "local-go-s3ec/v4/**/*.go"
+
+# Include required specifications here
+[[specification]]
+source = "../specification/s3-encryption/client.md"
+[[specification]]
+source = "../specification/s3-encryption/decryption.md"
+[[specification]]
+source = "../specification/s3-encryption/encryption.md"
+[[specification]]
+source = "../specification/s3-encryption/key-commitment.md"
+[[specification]]
+source = "../specification/s3-encryption/key-derivation.md"
+[[specification]]
+source = "../specification/s3-encryption/data-format/content-metadata.md"
+[[specification]]
+source = "../specification/s3-encryption/data-format/metadata-strategy.md"
+
+[report.html]
+enabled = true
+
+# Enable snapshots to prevent requirement coverage regressions
+[report.snapshot]
+enabled = false
diff --git a/test-server/go-v3-transition-server/Makefile b/test-server/go-v3-transition-server/Makefile
new file mode 100644
index 00000000..a254acdf
--- /dev/null
+++ b/test-server/go-v3-transition-server/Makefile
@@ -0,0 +1,39 @@
+# Makefile for S3 Encryption Client Testing
+
+.PHONY: build-server start-server stop-server wait-for-server
+
+PID_FILE := server.pid
+PORT := 8095
+
+build-server:
+ @echo "Building Go V3 Transition server..."
+ go mod tidy
+
+start-server:
+ @echo "Starting Go V3 Transition server..."
+ AWS_ACCESS_KEY_ID="$$AWS_ACCESS_KEY_ID" \
+ AWS_SECRET_ACCESS_KEY="$$AWS_SECRET_ACCESS_KEY" \
+ AWS_SESSION_TOKEN="$$AWS_SESSION_TOKEN" \
+ AWS_REGION="us-west-2" \
+ go run . > server.log 2>&1 & echo $$! > $(PID_FILE)
+ @echo "Go V3 Transition server starting..."
+
+stop-server:
+ @echo "Stopping server on port $(PORT)..."
+ @lsof -ti:$(PORT) | xargs kill -9 2>/dev/null || true
+ @if [ -f $(PID_FILE) ]; then \
+ pkill -P $$(cat $(PID_FILE)) 2>/dev/null || true; \
+ kill -9 $$(cat $(PID_FILE)) 2>/dev/null || true; \
+ rm -f $(PID_FILE); \
+ fi
+ @rm -f server.log
+ @echo "Server stopped"
+
+wait-for-server:
+ $(MAKE) -C .. wait-for-port PORT=$(PORT)
+
+duvet:
+ duvet report
+
+view-report-mac:
+ open .duvet/reports/report.html
diff --git a/test-server/go-v3-transition-server/README.md b/test-server/go-v3-transition-server/README.md
new file mode 100644
index 00000000..e7e226f7
--- /dev/null
+++ b/test-server/go-v3-transition-server/README.md
@@ -0,0 +1,23 @@
+# S3EC Go V3 Transition Test Server
+
+This is the Go implementation of the S3ECTestServer framework for S3EC Go V3 Transition. It provides a server implementation for testing Go S3 Encryption Client V3 Transition functionality.
+
+## Overview
+
+The S3EC Go test server implements the S3ECTestServer service defined in the shared Smithy model. It provides endpoints for:
+
+- Creating S3 Encryption Clients
+- Putting objects with encryption
+- Getting and decrypting objects
+
+## Usage
+
+To run the server:
+
+```console
+go run .
+```
+
+This will start the server running on port `8095`.
+
+The server is used as part of the testing framework to verify cross-language compatibility of the S3 Encryption Client implementations.
diff --git a/test-server/go-v3-transition-server/go.mod b/test-server/go-v3-transition-server/go.mod
new file mode 100644
index 00000000..50f1259a
--- /dev/null
+++ b/test-server/go-v3-transition-server/go.mod
@@ -0,0 +1,35 @@
+module github.com/aws/amazon-s3-encryption-client-python/test-server/go-server
+
+go 1.21
+
+require (
+ github.com/aws/amazon-s3-encryption-client-go/v3 v3.0.0
+ github.com/aws/aws-sdk-go-v2 v1.24.0
+ github.com/aws/aws-sdk-go-v2/config v1.26.1
+ github.com/aws/aws-sdk-go-v2/service/kms v1.27.4
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5
+ github.com/google/uuid v1.5.0
+ github.com/gorilla/mux v1.8.1
+)
+
+require (
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
+ github.com/aws/smithy-go v1.19.0 // indirect
+)
+
+// S3EC Go V4 is not released to pkg.go.dev as of writing.
+// It is included as a submodule and referenced locally.
+replace github.com/aws/amazon-s3-encryption-client-go/v3 => ./local-go-s3ec/v3
diff --git a/test-server/go-v3-transition-server/go.sum b/test-server/go-v3-transition-server/go.sum
new file mode 100644
index 00000000..1bb969a3
--- /dev/null
+++ b/test-server/go-v3-transition-server/go.sum
@@ -0,0 +1,45 @@
+
+github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk=
+github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=
+github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o=
+github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg=
+github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU=
+github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM=
+github.com/aws/aws-sdk-go-v2/service/kms v1.27.4 h1:c75pHGBV3h6WOsIjbJhLyOnlCPXzap45nbiP2Z5jk5M=
+github.com/aws/aws-sdk-go-v2/service/kms v1.27.4/go.mod h1:D9FVDkZjkZnnFHymJ3fPVz0zOUlNSd0xcIIVmmrAac8=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA=
+github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38=
+github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg=
+github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU=
+github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
+github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
+github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
diff --git a/test-server/go-v3-transition-server/local-go-s3ec b/test-server/go-v3-transition-server/local-go-s3ec
new file mode 160000
index 00000000..bf8a12f6
--- /dev/null
+++ b/test-server/go-v3-transition-server/local-go-s3ec
@@ -0,0 +1 @@
+Subproject commit bf8a12f61694d750a13a44f0a691dd7ced0ff904
diff --git a/test-server/go-v3-transition-server/main.go b/test-server/go-v3-transition-server/main.go
new file mode 100644
index 00000000..64556f12
--- /dev/null
+++ b/test-server/go-v3-transition-server/main.go
@@ -0,0 +1,385 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/aws/amazon-s3-encryption-client-go/v3/client"
+ "github.com/aws/amazon-s3-encryption-client-go/v3/materials"
+ "github.com/aws/amazon-s3-encryption-client-go/v3/commitment"
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/kms"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/google/uuid"
+ "github.com/gorilla/mux"
+)
+
+// Server represents the Go test server
+type Server struct {
+ clientCache map[string]*client.S3EncryptionClientV3
+ kmsClient *kms.Client
+ mu sync.RWMutex
+}
+
+// CreateClientInput represents the input for creating a client
+type CreateClientInput struct {
+ Config S3ECConfig `json:"config"`
+}
+
+// CreateClientOutput represents the output for creating a client
+type CreateClientOutput struct {
+ ClientID string `json:"clientId"`
+}
+
+// S3ECConfig represents the S3 encryption client configuration
+type S3ECConfig struct {
+ EnableLegacyUnauthenticatedModes bool `json:"enableLegacyUnauthenticatedModes"`
+ EnableDelayedAuthenticationMode bool `json:"enableDelayedAuthenticationMode"`
+ EnableLegacyWrappingAlgorithms bool `json:"enableLegacyWrappingAlgorithms"`
+ SetBufferSize int64 `json:"setBufferSize"`
+ KeyMaterial KeyMaterial `json:"keyMaterial"`
+ CommitmentPolicy string `json:"commitmentPolicy"`
+}
+
+// KeyMaterial represents the key material for encryption
+type KeyMaterial struct {
+ RSAKey []byte `json:"rsaKey"`
+ AESKey []byte `json:"aesKey"`
+ KMSKeyID string `json:"kmsKeyId"`
+}
+
+// PutObjectOutput represents the output for put object operation
+type PutObjectOutput struct {
+ Bucket string `json:"bucket"`
+ Key string `json:"key"`
+ Metadata []string `json:"metadata"`
+}
+
+// ErrorResponse represents an error response
+type ErrorResponse struct {
+ Type string `json:"__type"`
+ Message string `json:"message"`
+}
+
+// NewServer creates a new server instance
+func NewServer() (*Server, error) {
+ cfg, err := config.LoadDefaultConfig(
+ context.TODO(),
+ config.WithRegion("us-west-2"),
+ config.WithRetryMaxAttempts(5),
+ config.WithRetryMode(aws.RetryModeAdaptive),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to load AWS config: %w", err)
+ }
+
+ return &Server{
+ clientCache: make(map[string]*client.S3EncryptionClientV3),
+ kmsClient: kms.NewFromConfig(cfg),
+ }, nil
+}
+
+// createGenericServerError creates a generic server error response
+func (s *Server) createGenericServerError(w http.ResponseWriter, message string, statusCode int) {
+ // Echo error to console
+ log.Printf("[Go V3-Transition] GenericServerError: %s (Status: %d)", message, statusCode)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ json.NewEncoder(w).Encode(ErrorResponse{
+ Type: "software.amazon.encryption.s3#GenericServerError",
+ Message: message,
+ })
+}
+
+// createS3EncryptionClientError creates an S3 encryption client error response
+func (s *Server) createS3EncryptionClientError(w http.ResponseWriter, message string, statusCode int) {
+ // Echo error to console
+ log.Printf("[Go V3-Transition] S3EncryptionClientError: %s (Status: %d)", message, statusCode)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ json.NewEncoder(w).Encode(ErrorResponse{
+ Type: "software.amazon.encryption.s3#S3EncryptionClientError",
+ Message: message,
+ })
+}
+
+// metadataStringToMap converts metadata string to map
+func metadataStringToMap(mdString string) (map[string]string, error) {
+ md := make(map[string]string)
+ if mdString == "" {
+ return md, nil
+ }
+
+ mdList := strings.Split(mdString, ",")
+ for _, entry := range mdList {
+ // Split on "]:[" to separate key and value
+ parts := strings.Split(entry, "]:[")
+ if len(parts) == 2 {
+ // Remove remaining brackets from start and end
+ key := parts[0][1:] // Remove first character
+ value := parts[1][:len(parts[1])-1] // Remove last character
+ md[key] = value
+ } else {
+ return nil, fmt.Errorf("malformed metadata list entry: %s", entry)
+ }
+ }
+ return md, nil
+}
+
+// createClient handles POST /client
+func (s *Server) createClient(w http.ResponseWriter, r *http.Request) {
+ // Read body
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ s.createGenericServerError(w, "Failed to read request body", http.StatusBadRequest)
+ return
+ }
+
+ var input CreateClientInput
+ if err := json.Unmarshal(body, &input); err != nil {
+ s.createGenericServerError(w, "Invalid JSON in request body", http.StatusBadRequest)
+ return
+ }
+
+ cfg, err := config.LoadDefaultConfig(
+ context.TODO(),
+ config.WithRegion("us-west-2"),
+ config.WithRetryMaxAttempts(5),
+ config.WithRetryMode(aws.RetryModeAdaptive),
+ )
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to load AWS config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var commitmentPolicy commitment.CommitmentPolicy
+ switch input.Config.CommitmentPolicy {
+ case "REQUIRE_ENCRYPT_REQUIRE_DECRYPT":
+ commitmentPolicy = commitment.REQUIRE_ENCRYPT_REQUIRE_DECRYPT
+ case "REQUIRE_ENCRYPT_ALLOW_DECRYPT":
+ commitmentPolicy = commitment.REQUIRE_ENCRYPT_ALLOW_DECRYPT
+ case "FORBID_ENCRYPT_ALLOW_DECRYPT":
+ commitmentPolicy = commitment.FORBID_ENCRYPT_ALLOW_DECRYPT
+ }
+
+ // Create KMS keyring
+ kmsClient := kms.NewFromConfig(cfg)
+ keyring := materials.NewKmsKeyring(kmsClient, input.Config.KeyMaterial.KMSKeyID, func(options *materials.KeyringOptions) {
+ options.EnableLegacyWrappingAlgorithms = input.Config.EnableLegacyWrappingAlgorithms
+ })
+ cmm, err := materials.NewCryptographicMaterialsManager(keyring)
+
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to create CMM: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Create S3 encryption client
+ var s3EncryptionClient *client.S3EncryptionClientV3
+ s3PlaintextClient := s3.NewFromConfig(cfg)
+ s3EncryptionClient, err = client.New(s3PlaintextClient, cmm, func(clientOptions *client.EncryptionClientOptions) {
+ if input.Config.CommitmentPolicy != "" {
+ clientOptions.CommitmentPolicy = commitmentPolicy
+ }
+ clientOptions.EnableLegacyUnauthenticatedModes = input.Config.EnableLegacyUnauthenticatedModes
+ })
+
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to create S3EC: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Generate client ID
+ clientID := uuid.New().String()
+
+ // Store client in cache (protected by mutex)
+ s.mu.Lock()
+ s.clientCache[clientID] = s3EncryptionClient
+ s.mu.Unlock()
+
+ // Return response
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(CreateClientOutput{
+ ClientID: clientID,
+ })
+}
+
+// putObject handles PUT /object/{bucket}/{key}
+func (s *Server) putObject(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ bucket := vars["bucket"]
+ key := vars["key"]
+
+ clientID := r.Header.Get("ClientID")
+ if clientID == "" {
+ s.createGenericServerError(w, "ClientID header is required", http.StatusBadRequest)
+ return
+ }
+
+ // Get client from cache (protected by mutex)
+ s.mu.RLock()
+ client, exists := s.clientCache[clientID]
+ s.mu.RUnlock()
+
+ if !exists {
+ s.createGenericServerError(w, fmt.Sprintf("No client found for ClientID: %s", clientID), http.StatusNotFound)
+ return
+ }
+
+ // Read body
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ s.createGenericServerError(w, "Failed to read request body", http.StatusBadRequest)
+ return
+ }
+
+ // Get metadata from header
+ metadataHeader := r.Header.Get("Content-Metadata")
+ encCtx, err := metadataStringToMap(metadataHeader)
+
+ // Create context with encryption context
+ ctx := context.Background()
+ encryptionContext := context.WithValue(ctx, "EncryptionContext", encCtx)
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to parse metadata: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ // Create put object input
+ putInput := &s3.PutObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(key),
+ Body: strings.NewReader(string(body)),
+ }
+
+ // Add metadata if present
+ if len(encCtx) > 0 {
+ putInput.Metadata = encCtx
+ }
+
+ // Make the put object request using the encryption client
+ _, err = client.PutObject(encryptionContext, putInput)
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to put object: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ log.Printf("[Go V3-Transition] PutObject SUCCESS: Bucket=%s, Key=%s", bucket, key)
+
+ // Return response
+ w.Header().Set("Content-Type", "application/json")
+ resp := PutObjectOutput{
+ Bucket: bucket,
+ Key: key,
+ Metadata: []string{}, // TODO: pass metadata back in response
+ }
+ json.NewEncoder(w).Encode(resp)
+}
+
+// getObject handles GET /object/{bucket}/{key}
+func (s *Server) getObject(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ bucket := vars["bucket"]
+ key := vars["key"]
+
+ clientID := r.Header.Get("ClientID")
+ if clientID == "" {
+ s.createGenericServerError(w, "ClientID header is required", http.StatusBadRequest)
+ return
+ }
+
+ // Get client from cache (protected by mutex)
+ s.mu.RLock()
+ client, exists := s.clientCache[clientID]
+ s.mu.RUnlock()
+
+ if !exists {
+ s.createGenericServerError(w, fmt.Sprintf("No client found for ClientID: %s", clientID), http.StatusNotFound)
+ return
+ }
+
+ // Get metadata from header
+ metadataHeader := r.Header.Get("Content-Metadata")
+ encCtx, err := metadataStringToMap(metadataHeader)
+
+ ctx := context.Background()
+ encryptionContext := context.WithValue(ctx, "EncryptionContext", encCtx)
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to parse metadata: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ // Create get object input
+ getInput := &s3.GetObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(key),
+ }
+
+ // Make the get object request using the encryption client
+ result, err := client.GetObject(encryptionContext, getInput)
+ if err != nil {
+ errMsg := err.Error()
+ // Shim the S3EC error message to the error message expected by the test server.
+ // We don't want to change the S3EC error message but the test server expects a specific error message;
+ // This is the appropriate place to rewrite the error message.
+ if strings.Contains(errMsg, "to decrypt x-amz-cek-alg value `kms` you must enable legacyWrappingAlgorithms on the keyring") {
+ s.createS3EncryptionClientError(w, "Enable legacy wrapping algorithms to use legacy key wrapping algorithm: kms", http.StatusInternalServerError)
+ return
+ }
+
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to get object: %v", err), http.StatusInternalServerError)
+ return
+ }
+ defer result.Body.Close()
+
+ // Read the body
+ body, err := io.ReadAll(result.Body)
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to read object body: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Convert metadata to string format
+ var metadataList []string
+ if result.Metadata != nil {
+ for k, v := range result.Metadata {
+ metadataList = append(metadataList, fmt.Sprintf("%s=%s", k, v))
+ }
+ }
+
+ metadataStr := strings.Join(metadataList, ",")
+
+ log.Printf("[Go V3-Transition] GetObject SUCCESS: Bucket=%s, Key=%s", bucket, key)
+
+ // Set response headers
+ w.Header().Set("Content-Metadata", metadataStr)
+
+ // Return the body as response
+ w.Write(body)
+}
+
+func main() {
+ server, err := NewServer()
+ if err != nil {
+ log.Fatalf("[Go V3-Transition] Failed to create Go V3 Transition server: %v", err)
+ }
+
+ r := mux.NewRouter()
+
+ // Register routes
+ r.HandleFunc("/client", server.createClient).Methods("POST")
+ r.HandleFunc("/object/{bucket}/{key}", server.putObject).Methods("PUT")
+ r.HandleFunc("/object/{bucket}/{key}", server.getObject).Methods("GET")
+
+ fmt.Println("[Go V3-Transition] Starting Go V3 Transition server on :8095...")
+ log.Fatal(http.ListenAndServe(":8095", r))
+}
diff --git a/test-server/go-v4-server/.duvet/.gitignore b/test-server/go-v4-server/.duvet/.gitignore
new file mode 100644
index 00000000..93956e36
--- /dev/null
+++ b/test-server/go-v4-server/.duvet/.gitignore
@@ -0,0 +1,3 @@
+reports/
+requirements/
+specification/
\ No newline at end of file
diff --git a/test-server/go-v4-server/.duvet/config.toml b/test-server/go-v4-server/.duvet/config.toml
new file mode 100644
index 00000000..713e72d3
--- /dev/null
+++ b/test-server/go-v4-server/.duvet/config.toml
@@ -0,0 +1,27 @@
+'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json"
+
+[[source]]
+pattern = "local-go-s3ec/v4/**/*.go"
+
+# Include required specifications here
+[[specification]]
+source = "../specification/s3-encryption/client.md"
+[[specification]]
+source = "../specification/s3-encryption/decryption.md"
+[[specification]]
+source = "../specification/s3-encryption/encryption.md"
+[[specification]]
+source = "../specification/s3-encryption/key-commitment.md"
+[[specification]]
+source = "../specification/s3-encryption/key-derivation.md"
+[[specification]]
+source = "../specification/s3-encryption/data-format/content-metadata.md"
+[[specification]]
+source = "../specification/s3-encryption/data-format/metadata-strategy.md"
+
+[report.html]
+enabled = true
+
+# Enable snapshots to prevent requirement coverage regressions
+[report.snapshot]
+enabled = false
diff --git a/test-server/go-v4-server/Makefile b/test-server/go-v4-server/Makefile
new file mode 100644
index 00000000..6c549db2
--- /dev/null
+++ b/test-server/go-v4-server/Makefile
@@ -0,0 +1,39 @@
+# Makefile for S3 Encryption Client Testing
+
+.PHONY: build-server start-server stop-server wait-for-server
+
+PID_FILE := server.pid
+PORT := 8089
+
+build-server:
+ @echo "Building Go V4 server..."
+ go mod tidy
+
+start-server:
+ @echo "Starting Go V4 server..."
+ AWS_ACCESS_KEY_ID="$$AWS_ACCESS_KEY_ID" \
+ AWS_SECRET_ACCESS_KEY="$$AWS_SECRET_ACCESS_KEY" \
+ AWS_SESSION_TOKEN="$$AWS_SESSION_TOKEN" \
+ AWS_REGION="us-west-2" \
+ go run . > server.log 2>&1 & echo $$! > $(PID_FILE)
+ @echo "Go V4 server starting..."
+
+stop-server:
+ @echo "Stopping server on port $(PORT)..."
+ @lsof -ti:$(PORT) | xargs kill -9 2>/dev/null || true
+ @if [ -f $(PID_FILE) ]; then \
+ pkill -P $$(cat $(PID_FILE)) 2>/dev/null || true; \
+ kill -9 $$(cat $(PID_FILE)) 2>/dev/null || true; \
+ rm -f $(PID_FILE); \
+ fi
+ @rm -f server.log
+ @echo "Server stopped"
+
+wait-for-server:
+ $(MAKE) -C .. wait-for-port PORT=$(PORT)
+
+duvet:
+ duvet report
+
+view-report-mac:
+ open .duvet/reports/report.html
diff --git a/test-server/go-v4-server/README.md b/test-server/go-v4-server/README.md
new file mode 100644
index 00000000..d97a37bf
--- /dev/null
+++ b/test-server/go-v4-server/README.md
@@ -0,0 +1,23 @@
+# S3EC Go V4 Test Server
+
+This is the Go implementation of the S3ECTestServer framework for S3EC Go V4. It provides a server implementation for testing Go S3 Encryption Client V4 functionality.
+
+## Overview
+
+The S3EC Go test server implements the S3ECTestServer service defined in the shared Smithy model. It provides endpoints for:
+
+- Creating S3 Encryption Clients
+- Putting objects with encryption
+- Getting and decrypting objects
+
+## Usage
+
+To run the server:
+
+```console
+go run .
+```
+
+This will start the server running on port `8089`.
+
+The server is used as part of the testing framework to verify cross-language compatibility of the S3 Encryption Client implementations.
diff --git a/test-server/go-v4-server/go.mod b/test-server/go-v4-server/go.mod
new file mode 100644
index 00000000..33b1cc9f
--- /dev/null
+++ b/test-server/go-v4-server/go.mod
@@ -0,0 +1,35 @@
+module github.com/aws/amazon-s3-encryption-client-python/test-server/go-server
+
+go 1.24
+
+require (
+ github.com/aws/amazon-s3-encryption-client-go/v4 v4.0.0
+ github.com/aws/aws-sdk-go-v2 v1.24.0
+ github.com/aws/aws-sdk-go-v2/config v1.26.1
+ github.com/aws/aws-sdk-go-v2/service/kms v1.27.4
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5
+ github.com/google/uuid v1.5.0
+ github.com/gorilla/mux v1.8.1
+)
+
+require (
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.16.12 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect
+ github.com/aws/smithy-go v1.19.0 // indirect
+)
+
+// S3EC Go V4 is not released to pkg.go.dev as of writing.
+// It is included as a submodule and referenced locally.
+replace github.com/aws/amazon-s3-encryption-client-go/v4 => ./local-go-s3ec/v4
diff --git a/test-server/go-v4-server/go.sum b/test-server/go-v4-server/go.sum
new file mode 100644
index 00000000..f4e3646a
--- /dev/null
+++ b/test-server/go-v4-server/go.sum
@@ -0,0 +1,44 @@
+github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk=
+github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo=
+github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o=
+github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg=
+github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU=
+github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM=
+github.com/aws/aws-sdk-go-v2/service/kms v1.27.4 h1:c75pHGBV3h6WOsIjbJhLyOnlCPXzap45nbiP2Z5jk5M=
+github.com/aws/aws-sdk-go-v2/service/kms v1.27.4/go.mod h1:D9FVDkZjkZnnFHymJ3fPVz0zOUlNSd0xcIIVmmrAac8=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA=
+github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38=
+github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg=
+github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU=
+github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM=
+github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE=
+github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
+github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
+github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
diff --git a/test-server/go-v4-server/local-go-s3ec b/test-server/go-v4-server/local-go-s3ec
new file mode 160000
index 00000000..bf8a12f6
--- /dev/null
+++ b/test-server/go-v4-server/local-go-s3ec
@@ -0,0 +1 @@
+Subproject commit bf8a12f61694d750a13a44f0a691dd7ced0ff904
diff --git a/test-server/go-v4-server/main.go b/test-server/go-v4-server/main.go
new file mode 100644
index 00000000..50999e95
--- /dev/null
+++ b/test-server/go-v4-server/main.go
@@ -0,0 +1,385 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/aws/amazon-s3-encryption-client-go/v4/client"
+ "github.com/aws/amazon-s3-encryption-client-go/v4/materials"
+ "github.com/aws/amazon-s3-encryption-client-go/v4/commitment"
+ "github.com/aws/aws-sdk-go-v2/aws"
+ "github.com/aws/aws-sdk-go-v2/config"
+ "github.com/aws/aws-sdk-go-v2/service/kms"
+ "github.com/aws/aws-sdk-go-v2/service/s3"
+ "github.com/google/uuid"
+ "github.com/gorilla/mux"
+)
+
+// Server represents the Go test server
+type Server struct {
+ clientCache map[string]*client.S3EncryptionClientV4
+ kmsClient *kms.Client
+ mu sync.RWMutex
+}
+
+// CreateClientInput represents the input for creating a client
+type CreateClientInput struct {
+ Config S3ECConfig `json:"config"`
+}
+
+// CreateClientOutput represents the output for creating a client
+type CreateClientOutput struct {
+ ClientID string `json:"clientId"`
+}
+
+// S3ECConfig represents the S3 encryption client configuration
+type S3ECConfig struct {
+ EnableLegacyUnauthenticatedModes bool `json:"enableLegacyUnauthenticatedModes"`
+ EnableDelayedAuthenticationMode bool `json:"enableDelayedAuthenticationMode"`
+ EnableLegacyWrappingAlgorithms bool `json:"enableLegacyWrappingAlgorithms"`
+ SetBufferSize int64 `json:"setBufferSize"`
+ KeyMaterial KeyMaterial `json:"keyMaterial"`
+ CommitmentPolicy string `json:"commitmentPolicy"`
+}
+
+// KeyMaterial represents the key material for encryption
+type KeyMaterial struct {
+ RSAKey []byte `json:"rsaKey"`
+ AESKey []byte `json:"aesKey"`
+ KMSKeyID string `json:"kmsKeyId"`
+}
+
+// PutObjectOutput represents the output for put object operation
+type PutObjectOutput struct {
+ Bucket string `json:"bucket"`
+ Key string `json:"key"`
+ Metadata []string `json:"metadata"`
+}
+
+// ErrorResponse represents an error response
+type ErrorResponse struct {
+ Type string `json:"__type"`
+ Message string `json:"message"`
+}
+
+// NewServer creates a new server instance
+func NewServer() (*Server, error) {
+ cfg, err := config.LoadDefaultConfig(
+ context.TODO(),
+ config.WithRegion("us-west-2"),
+ config.WithRetryMaxAttempts(5),
+ config.WithRetryMode(aws.RetryModeAdaptive),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to load AWS config: %w", err)
+ }
+
+ return &Server{
+ clientCache: make(map[string]*client.S3EncryptionClientV4),
+ kmsClient: kms.NewFromConfig(cfg),
+ }, nil
+}
+
+// createGenericServerError creates a generic server error response
+func (s *Server) createGenericServerError(w http.ResponseWriter, message string, statusCode int) {
+ // Echo error to console
+ log.Printf("[Go V4] GenericServerError: %s (Status: %d)", message, statusCode)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ json.NewEncoder(w).Encode(ErrorResponse{
+ Type: "software.amazon.encryption.s3#GenericServerError",
+ Message: message,
+ })
+}
+
+// createS3EncryptionClientError creates an S3 encryption client error response
+func (s *Server) createS3EncryptionClientError(w http.ResponseWriter, message string, statusCode int) {
+ // Echo error to console
+ log.Printf("[Go V4] S3EncryptionClientError: %s (Status: %d)", message, statusCode)
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(statusCode)
+ json.NewEncoder(w).Encode(ErrorResponse{
+ Type: "software.amazon.encryption.s3#S3EncryptionClientError",
+ Message: message,
+ })
+}
+
+// metadataStringToMap converts metadata string to map
+func metadataStringToMap(mdString string) (map[string]string, error) {
+ md := make(map[string]string)
+ if mdString == "" {
+ return md, nil
+ }
+
+ mdList := strings.Split(mdString, ",")
+ for _, entry := range mdList {
+ // Split on "]:[" to separate key and value
+ parts := strings.Split(entry, "]:[")
+ if len(parts) == 2 {
+ // Remove remaining brackets from start and end
+ key := parts[0][1:] // Remove first character
+ value := parts[1][:len(parts[1])-1] // Remove last character
+ md[key] = value
+ } else {
+ return nil, fmt.Errorf("malformed metadata list entry: %s", entry)
+ }
+ }
+ return md, nil
+}
+
+// createClient handles POST /client
+func (s *Server) createClient(w http.ResponseWriter, r *http.Request) {
+ // Read body
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ s.createGenericServerError(w, "Failed to read request body", http.StatusBadRequest)
+ return
+ }
+
+ var input CreateClientInput
+ if err := json.Unmarshal(body, &input); err != nil {
+ s.createGenericServerError(w, "Invalid JSON in request body", http.StatusBadRequest)
+ return
+ }
+
+ cfg, err := config.LoadDefaultConfig(
+ context.TODO(),
+ config.WithRegion("us-west-2"),
+ config.WithRetryMaxAttempts(5),
+ config.WithRetryMode(aws.RetryModeAdaptive),
+ )
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to load AWS config: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ var commitmentPolicy commitment.CommitmentPolicy
+ switch input.Config.CommitmentPolicy {
+ case "REQUIRE_ENCRYPT_REQUIRE_DECRYPT":
+ commitmentPolicy = commitment.REQUIRE_ENCRYPT_REQUIRE_DECRYPT
+ case "REQUIRE_ENCRYPT_ALLOW_DECRYPT":
+ commitmentPolicy = commitment.REQUIRE_ENCRYPT_ALLOW_DECRYPT
+ case "FORBID_ENCRYPT_ALLOW_DECRYPT":
+ commitmentPolicy = commitment.FORBID_ENCRYPT_ALLOW_DECRYPT
+ }
+
+ // Create KMS keyring
+ kmsClient := kms.NewFromConfig(cfg)
+ keyring := materials.NewKmsKeyring(kmsClient, input.Config.KeyMaterial.KMSKeyID, func(options *materials.KeyringOptions) {
+ options.EnableLegacyWrappingAlgorithms = input.Config.EnableLegacyWrappingAlgorithms
+ })
+ cmm, err := materials.NewCryptographicMaterialsManager(keyring)
+
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to create CMM: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Create S3 encryption client
+ var s3EncryptionClient *client.S3EncryptionClientV4
+ s3PlaintextClient := s3.NewFromConfig(cfg)
+ s3EncryptionClient, err = client.New(s3PlaintextClient, cmm, func(clientOptions *client.EncryptionClientOptions) {
+ if input.Config.CommitmentPolicy != "" {
+ clientOptions.CommitmentPolicy = commitmentPolicy
+ }
+ clientOptions.EnableLegacyUnauthenticatedModes = input.Config.EnableLegacyUnauthenticatedModes
+ })
+
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to create S3EC: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Generate client ID
+ clientID := uuid.New().String()
+
+ // Store client in cache (protected by mutex)
+ s.mu.Lock()
+ s.clientCache[clientID] = s3EncryptionClient
+ s.mu.Unlock()
+
+ // Return response
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(CreateClientOutput{
+ ClientID: clientID,
+ })
+}
+
+// putObject handles PUT /object/{bucket}/{key}
+func (s *Server) putObject(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ bucket := vars["bucket"]
+ key := vars["key"]
+
+ clientID := r.Header.Get("ClientID")
+ if clientID == "" {
+ s.createGenericServerError(w, "ClientID header is required", http.StatusBadRequest)
+ return
+ }
+
+ // Get client from cache (protected by mutex)
+ s.mu.RLock()
+ client, exists := s.clientCache[clientID]
+ s.mu.RUnlock()
+
+ if !exists {
+ s.createGenericServerError(w, fmt.Sprintf("No client found for ClientID: %s", clientID), http.StatusNotFound)
+ return
+ }
+
+ // Read body
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ s.createGenericServerError(w, "Failed to read request body", http.StatusBadRequest)
+ return
+ }
+
+ // Get metadata from header
+ metadataHeader := r.Header.Get("Content-Metadata")
+ encCtx, err := metadataStringToMap(metadataHeader)
+
+ // Create context with encryption context
+ ctx := context.Background()
+ encryptionContext := context.WithValue(ctx, "EncryptionContext", encCtx)
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to parse metadata: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ // Create put object input
+ putInput := &s3.PutObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(key),
+ Body: strings.NewReader(string(body)),
+ }
+
+ // Add metadata if present
+ if len(encCtx) > 0 {
+ putInput.Metadata = encCtx
+ }
+
+ // Make the put object request using the encryption client
+ _, err = client.PutObject(encryptionContext, putInput)
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to put object: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ log.Printf("[Go V4] PutObject SUCCESS: Bucket=%s, Key=%s", bucket, key)
+
+ // Return response
+ w.Header().Set("Content-Type", "application/json")
+ resp := PutObjectOutput{
+ Bucket: bucket,
+ Key: key,
+ Metadata: []string{}, // TODO: pass metadata back in response
+ }
+ json.NewEncoder(w).Encode(resp)
+}
+
+// getObject handles GET /object/{bucket}/{key}
+func (s *Server) getObject(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ bucket := vars["bucket"]
+ key := vars["key"]
+
+ clientID := r.Header.Get("ClientID")
+ if clientID == "" {
+ s.createGenericServerError(w, "ClientID header is required", http.StatusBadRequest)
+ return
+ }
+
+ // Get client from cache (protected by mutex)
+ s.mu.RLock()
+ client, exists := s.clientCache[clientID]
+ s.mu.RUnlock()
+
+ if !exists {
+ s.createGenericServerError(w, fmt.Sprintf("No client found for ClientID: %s", clientID), http.StatusNotFound)
+ return
+ }
+
+ // Get metadata from header
+ metadataHeader := r.Header.Get("Content-Metadata")
+ encCtx, err := metadataStringToMap(metadataHeader)
+
+ ctx := context.Background()
+ encryptionContext := context.WithValue(ctx, "EncryptionContext", encCtx)
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to parse metadata: %v", err), http.StatusBadRequest)
+ return
+ }
+
+ // Create get object input
+ getInput := &s3.GetObjectInput{
+ Bucket: aws.String(bucket),
+ Key: aws.String(key),
+ }
+
+ // Make the get object request using the encryption client
+ result, err := client.GetObject(encryptionContext, getInput)
+ if err != nil {
+ errMsg := err.Error()
+ // Shim the S3EC error message to the error message expected by the test server.
+ // We don't want to change the S3EC error message but the test server expects a specific error message;
+ // This is the appropriate place to rewrite the error message.
+ if strings.Contains(errMsg, "to decrypt x-amz-cek-alg value `kms` you must enable legacyWrappingAlgorithms on the keyring") {
+ s.createS3EncryptionClientError(w, "Enable legacy wrapping algorithms to use legacy key wrapping algorithm: kms", http.StatusInternalServerError)
+ return
+ }
+
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to get object: %v", err), http.StatusInternalServerError)
+ return
+ }
+ defer result.Body.Close()
+
+ // Read the body
+ body, err := io.ReadAll(result.Body)
+ if err != nil {
+ s.createS3EncryptionClientError(w, fmt.Sprintf("Failed to read object body: %v", err), http.StatusInternalServerError)
+ return
+ }
+
+ // Convert metadata to string format
+ var metadataList []string
+ if result.Metadata != nil {
+ for k, v := range result.Metadata {
+ metadataList = append(metadataList, fmt.Sprintf("%s=%s", k, v))
+ }
+ }
+
+ metadataStr := strings.Join(metadataList, ",")
+
+ log.Printf("[Go V4] GetObject SUCCESS: Bucket=%s, Key=%s", bucket, key)
+
+ // Set response headers
+ w.Header().Set("Content-Metadata", metadataStr)
+
+ // Return the body as response
+ w.Write(body)
+}
+
+func main() {
+ server, err := NewServer()
+ if err != nil {
+ log.Fatalf("[Go V4] Failed to create Go V4 server: %v", err)
+ }
+
+ r := mux.NewRouter()
+
+ // Register routes
+ r.HandleFunc("/client", server.createClient).Methods("POST")
+ r.HandleFunc("/object/{bucket}/{key}", server.putObject).Methods("PUT")
+ r.HandleFunc("/object/{bucket}/{key}", server.getObject).Methods("GET")
+
+ fmt.Println("[Go V4] Starting Go V4 server on :8089...")
+ log.Fatal(http.ListenAndServe(":8089", r))
+}
diff --git a/test-server/gradle.init b/test-server/gradle.init
new file mode 100644
index 00000000..7091c254
--- /dev/null
+++ b/test-server/gradle.init
@@ -0,0 +1,57 @@
+// Global initialization script for Gradle
+// This applies to all Gradle builds in subdirectories
+
+gradle.projectsLoaded {
+ rootProject.allprojects {
+ buildscript {
+ // Configure build script classpath repositories
+ repositories {
+ mavenLocal()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+ }
+ }
+}
+
+// Apply common settings to all projects
+allprojects {
+ // Configure project repositories
+ repositories {
+ mavenLocal()
+ mavenCentral()
+ }
+
+ // Configure tasks
+ tasks.withType(JavaCompile) {
+ options.incremental = true
+ options.fork = true
+ }
+
+ // Configure test tasks
+ tasks.withType(Test) {
+ maxParallelForks = Runtime.runtime.availableProcessors().intdiv(2) ?: 1
+ testLogging {
+ events "passed", "skipped", "failed"
+ exceptionFormat = 'full'
+ }
+ }
+}
+
+// Initialize Gradle with optimized settings if not already set
+startParameter.with {
+ if (!systemPropertiesDefined('org.gradle.parallel')) {
+ systemProperties['org.gradle.parallel'] = 'true'
+ }
+ if (!systemPropertiesDefined('org.gradle.caching')) {
+ systemProperties['org.gradle.caching'] = 'true'
+ }
+ if (!systemPropertiesDefined('org.gradle.daemon')) {
+ systemProperties['org.gradle.daemon'] = 'true'
+ }
+}
+
+// Helper method to check if a system property is defined
+boolean systemPropertiesDefined(String property) {
+ return System.properties.containsKey(property) || startParameter.systemPropertiesArgs.containsKey(property)
+}
diff --git a/test-server/java-tests/.gitignore b/test-server/java-tests/.gitignore
new file mode 100644
index 00000000..0cde3479
--- /dev/null
+++ b/test-server/java-tests/.gitignore
@@ -0,0 +1,21 @@
+# Ignore Gradle project-specific cache directory
+.gradle
+
+# Ignore kotlin cache dir
+.kotlin
+
+# Ignore Gradle build output directory
+build
+
+# Ignore intellij files
+.idea
+
+#Ignore mac files
+.DS_Store
+
+# Intellij stuff
+.classpath
+.project
+.settings
+
+smithy-java-core/out
diff --git a/test-server/java-tests/README.md b/test-server/java-tests/README.md
new file mode 100644
index 00000000..2a9b80ee
--- /dev/null
+++ b/test-server/java-tests/README.md
@@ -0,0 +1,13 @@
+# Java Tests
+
+This project contains Java client tests for the S3 Encryption Client.
+
+## Running Tests
+
+To run the integration tests for this project:
+
+```console
+gradle integ
+```
+
+The integration tests will connect to the appropriate test servers automatically.
diff --git a/test-server/java-tests/build.gradle.kts b/test-server/java-tests/build.gradle.kts
new file mode 100644
index 00000000..2d1cbdeb
--- /dev/null
+++ b/test-server/java-tests/build.gradle.kts
@@ -0,0 +1,83 @@
+plugins {
+ `java-library`
+ id("software.amazon.smithy.gradle.smithy-base")
+}
+
+dependencies {
+ val smithyJavaVersion: String by project
+
+ smithyBuild("software.amazon.smithy.java:plugins:$smithyJavaVersion")
+ implementation("software.amazon.smithy:smithy-rules-engine:1.59.0")
+
+ // Client dependencies
+ implementation("software.amazon.smithy.java:aws-client-restjson:$smithyJavaVersion")
+ implementation("software.amazon.smithy.java:client-core:$smithyJavaVersion")
+
+ // Test dependencies
+ testImplementation("org.junit.jupiter:junit-jupiter:5.13.0")
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ // JUnit Suite support for test ordering
+ testImplementation("org.junit.platform:junit-platform-suite-api:1.10.0")
+ testRuntimeOnly("org.junit.platform:junit-platform-suite-engine:1.10.0")
+ testImplementation("com.amazonaws:aws-java-sdk:1.12.788")
+ testImplementation("software.amazon.awssdk:s3:2.37.1")
+ testImplementation("org.bouncycastle:bcprov-jdk15on:1.70")
+}
+
+// Add generated Java sources to the main sourceset
+afterEvaluate {
+ val clientPath = smithy.getPluginProjectionPath(smithy.sourceProjection.get(), "java-client-codegen")
+ sourceSets {
+ main {
+ java {
+ srcDir(clientPath)
+ }
+ }
+ create("it") {
+ compileClasspath += main.get().output + configurations["testRuntimeClasspath"] + configurations["testCompileClasspath"]
+ runtimeClasspath += output + compileClasspath + test.get().runtimeClasspath + test.get().output
+ }
+ }
+}
+
+tasks {
+ val smithyBuild by getting
+ compileJava {
+ dependsOn(smithyBuild)
+ }
+
+ val integ by registering(Test::class) {
+ useJUnitPlatform()
+ testClassesDirs = sourceSets["it"].output.classesDirs
+ classpath = sourceSets["it"].runtimeClasspath
+ outputs.upToDateWhen { false }
+ outputs.cacheIf { false }
+
+ // Enable parallel test execution
+ systemProperty("junit.jupiter.execution.parallel.enabled", "true")
+ systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent")
+ systemProperty("junit.jupiter.execution.parallel.mode.classes.default", "concurrent")
+ // Configure thread pool size - adjust based on I/O-bound nature of tests
+ systemProperty("junit.jupiter.execution.parallel.config.strategy", "fixed")
+ maxParallelForks = 1 // One JVM
+ systemProperty("junit.jupiter.execution.parallel.config.fixed.parallelism",
+ Math.max(1, Runtime.getRuntime().availableProcessors() - 3).toString()) // Scale with CPU, reserve 3 cores
+
+ // Passing information from Gradle into the tests so that we can filter our servers
+ systemProperty("test.filter.servers", System.getProperty("test.filter.servers"))
+ // For debugging
+ // // Enable System.out output
+ // testLogging {
+ // events("passed", "skipped", "failed", "standardOut", "standardError")
+ // showStandardStreams = true
+ // }
+
+ // // Disable AWS SDK v1 deprecation warnings
+ // systemProperty("aws.java.v1.disableDeprecationAnnouncement", "true")
+ }
+}
+
+repositories {
+ mavenLocal()
+ mavenCentral()
+}
diff --git a/test-server/java-tests/gradle.properties b/test-server/java-tests/gradle.properties
new file mode 100644
index 00000000..08afce82
--- /dev/null
+++ b/test-server/java-tests/gradle.properties
@@ -0,0 +1,11 @@
+# Smithy versions
+smithyJavaVersion=[0,1]
+smithyGradleVersion=1.1.0
+smithyVersion=[1,2]
+
+# Performance optimization settings
+org.gradle.parallel=true
+org.gradle.caching=true
+org.gradle.daemon=true
+org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
+org.gradle.workers.max=4
diff --git a/test-server/java-tests/gradle/wrapper/gradle-wrapper.jar b/test-server/java-tests/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e6441136
Binary files /dev/null and b/test-server/java-tests/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/test-server/java-tests/gradle/wrapper/gradle-wrapper.properties b/test-server/java-tests/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..a4413138
--- /dev/null
+++ b/test-server/java-tests/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/test-server/java-tests/gradlew b/test-server/java-tests/gradlew
new file mode 100755
index 00000000..b740cf13
--- /dev/null
+++ b/test-server/java-tests/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/test-server/java-tests/gradlew.bat b/test-server/java-tests/gradlew.bat
new file mode 100644
index 00000000..7101f8e4
--- /dev/null
+++ b/test-server/java-tests/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/test-server/java-tests/license.txt b/test-server/java-tests/license.txt
new file mode 100644
index 00000000..edaafd85
--- /dev/null
+++ b/test-server/java-tests/license.txt
@@ -0,0 +1,4 @@
+/*
+ * Example file license header.
+ * File header line two
+ */
diff --git a/test-server/java-tests/settings.gradle.kts b/test-server/java-tests/settings.gradle.kts
new file mode 100644
index 00000000..ae20971f
--- /dev/null
+++ b/test-server/java-tests/settings.gradle.kts
@@ -0,0 +1,19 @@
+/**
+ * Java client tests for S3 Encryption Client.
+ */
+
+pluginManagement {
+ val smithyGradleVersion: String by settings
+
+ plugins {
+ id("software.amazon.smithy.gradle.smithy-base").version(smithyGradleVersion)
+ }
+
+ repositories {
+ mavenLocal()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+rootProject.name = "Java-Tests"
diff --git a/test-server/java-tests/smithy-build.json b/test-server/java-tests/smithy-build.json
new file mode 100644
index 00000000..3fe72762
--- /dev/null
+++ b/test-server/java-tests/smithy-build.json
@@ -0,0 +1,12 @@
+{
+ "version": "1.0",
+ "plugins": {
+ "java-client-codegen": {
+ "service": "software.amazon.encryption.s3#S3ECTestServer",
+ "namespace": "software.amazon.encryption.s3",
+ "headerFile": "license.txt",
+ "protocol": "aws.protocols#restJson1"
+ }
+ },
+ "sources": ["../model"]
+}
diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/CBCDecryptTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/CBCDecryptTests.java
new file mode 100644
index 00000000..093ba2ab
--- /dev/null
+++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/CBCDecryptTests.java
@@ -0,0 +1,184 @@
+/*
+* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+* SPDX-License-Identifier: Apache-2.0
+*/
+
+package software.amazon.encryption.s3;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static software.amazon.encryption.s3.TestUtils.*;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import com.amazonaws.services.s3.model.KMSEncryptionMaterials;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.api.Nested;
+import software.amazon.encryption.s3.client.S3ECTestServerClient;
+import software.amazon.encryption.s3.model.CommitmentPolicy;
+import software.amazon.encryption.s3.model.CreateClientInput;
+import software.amazon.encryption.s3.model.CreateClientOutput;
+import software.amazon.encryption.s3.model.GetObjectInput;
+import software.amazon.encryption.s3.model.GetObjectOutput;
+import software.amazon.encryption.s3.model.KeyMaterial;
+import software.amazon.encryption.s3.model.PutObjectInput;
+import software.amazon.encryption.s3.model.S3ECConfig;
+import software.amazon.encryption.s3.model.S3EncryptionClientError;
+import software.amazon.encryption.s3.model.EncryptionAlgorithm;
+
+import com.amazonaws.services.s3.AmazonS3Encryption;
+import com.amazonaws.services.s3.AmazonS3EncryptionClient;
+import com.amazonaws.services.s3.model.CryptoConfiguration;
+import com.amazonaws.services.s3.model.CryptoMode;
+import com.amazonaws.services.s3.model.CryptoStorageMode;
+import software.amazon.encryption.s3.TestUtils.*;
+import com.amazonaws.services.s3.model.EncryptionMaterialsProvider;
+import com.amazonaws.services.s3.model.KMSEncryptionMaterialsProvider;
+
+/**
+* Exhaustive tests for S3 Encryption Client round-trip operations.
+* These tests cover various combinations of client versions, commitment policies, and encryption modes.
+*
+* Tests are based on the exhaustive test matrix defined at:
+* https://tiny.amazon.com/3xnzwczl/loopcloumicrpeyJ3
+*
+* These tests deal with decrypting CBC messages
+*/
+
+class CBCDecryptTests {
+ private static String sharedObjectKey = appendTestSuffix("test-cbc-kms-v1-");
+ private static KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+
+ @BeforeAll
+ static void encryptCBCObject() {
+ // Create the object using the old client
+ // V1 Client
+ EncryptionMaterialsProvider materialsProvider = new KMSEncryptionMaterialsProvider(TestUtils.KMS_KEY_ARN);
+
+ CryptoConfiguration v1Config =
+ new CryptoConfiguration(CryptoMode.EncryptionOnly)
+ .withStorageMode(CryptoStorageMode.ObjectMetadata)
+ .withAwsKmsRegion(TestUtils.KMS_REGION);
+
+ AmazonS3Encryption v1Client = AmazonS3EncryptionClient.encryptionBuilder()
+ .withCryptoConfiguration(v1Config)
+ .withEncryptionMaterials(materialsProvider)
+ .build();
+
+ v1Client.putObject(TestUtils.BUCKET, sharedObjectKey, sharedObjectKey);
+ }
+
+ @ParameterizedTest(name = "{0}: Transition configured with the default should decrypt CBC")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#transitionClientsForTest")
+ void transition_configured_with_the_default_should_decrypt_cbc(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ // .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .enableLegacyUnauthenticatedModes(true)
+ .enableLegacyWrappingAlgorithms(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, Arrays.asList(sharedObjectKey), EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Transition configured with ForbidEncryptAllowDecrypt should decrypt CBC")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#transitionClientsForTest")
+ void transition_configured_with_forbid_encrypt_allow_decrypt_should_decrypt_cbc(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .enableLegacyWrappingAlgorithms(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, Arrays.asList(sharedObjectKey), EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with ForbidEncryptAllowDecrypt should decrypt CBC")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_forbid_encrypt_allow_decrypt_should_decrypt_cbc(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient decClient = TestUtils.testServerClientFor(language);
+ CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .enableLegacyWrappingAlgorithms(true)
+ .build())
+ .build());
+ String decS3ECId = decClientOutput.getClientId();
+
+ TestUtils.Decrypt(decClient, decS3ECId, Arrays.asList(sharedObjectKey), EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptAllowDecrypt should decrypt CBC")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_allow_decrypt_should_decrypt_cbc(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient decClient = TestUtils.testServerClientFor(language);
+ CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT)
+ .enableLegacyUnauthenticatedModes(true)
+ .enableLegacyWrappingAlgorithms(true)
+ .build())
+ .build());
+ String decS3ECId = decClientOutput.getClientId();
+
+ TestUtils.Decrypt(decClient, decS3ECId, Arrays.asList(sharedObjectKey), EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptRequireDecrypt should fail to decrypt CBC")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_require_decrypt_should_fail_to_decrypt_cbc(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient decClient = TestUtils.testServerClientFor(language);
+ CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String decS3ECId = decClientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(decClient, decS3ECId, Arrays.asList(sharedObjectKey), EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with the default should fail to decrypt CBC")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_the_default_should_fail_to_decrypt_cbc(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient decClient = TestUtils.testServerClientFor(language);
+ CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .build())
+ .build());
+ String decS3ECId = decClientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(decClient, decS3ECId, Arrays.asList(sharedObjectKey), EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF);
+ }
+}
diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/ExhaustiveRoundTripTests1_25.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/ExhaustiveRoundTripTests1_25.java
new file mode 100644
index 00000000..b161feb6
--- /dev/null
+++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/ExhaustiveRoundTripTests1_25.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.encryption.s3;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static software.amazon.encryption.s3.TestUtils.*;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import com.amazonaws.services.s3.model.KMSEncryptionMaterials;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.encryption.s3.client.S3ECTestServerClient;
+import software.amazon.encryption.s3.model.CommitmentPolicy;
+import software.amazon.encryption.s3.model.CreateClientInput;
+import software.amazon.encryption.s3.model.CreateClientOutput;
+import software.amazon.encryption.s3.model.EncryptionAlgorithm;
+import software.amazon.encryption.s3.model.GetObjectInput;
+import software.amazon.encryption.s3.model.GetObjectOutput;
+import software.amazon.encryption.s3.model.KeyMaterial;
+import software.amazon.encryption.s3.model.PutObjectInput;
+import software.amazon.encryption.s3.model.S3ECConfig;
+import software.amazon.encryption.s3.model.S3EncryptionClientError;
+
+import com.amazonaws.services.s3.AmazonS3Encryption;
+import com.amazonaws.services.s3.AmazonS3EncryptionClient;
+import com.amazonaws.services.s3.model.CryptoConfiguration;
+import com.amazonaws.services.s3.model.CryptoMode;
+import com.amazonaws.services.s3.model.CryptoStorageMode;
+import software.amazon.encryption.s3.TestUtils.*;
+import com.amazonaws.services.s3.model.EncryptionMaterialsProvider;
+import com.amazonaws.services.s3.model.KMSEncryptionMaterialsProvider;
+
+/**
+ * Exhaustive tests for S3 Encryption Client round-trip operations.
+ * These tests cover various combinations of client versions, commitment policies, and encryption modes.
+ *
+ * Tests are based on the exhaustive test matrix defined at:
+ * https://tiny.amazon.com/3xnzwczl/loopcloumicrpeyJ3
+ *
+ * Tests 1-25 are included in this file.
+ */
+public class ExhaustiveRoundTripTests1_25 {
+
+ @BeforeAll
+ public static void setup() {
+ TestUtils.validateServersRunning();
+ }
+
+ // Begin Exhaustive tests defined here:
+ // https://tiny.amazon.com/3xnzwczl/loopcloumicrpeyJ3
+
+
+ // Exhaustive test 2
+ // Outcome Version Operation Policy Content Encryption
+ // Pass Improved Decrypt ForbidEncryptAllowDecrypt CBC
+
+ @ParameterizedTest(name = "{displayName} for Encrypt: Java-V1, Decrypt: {0}")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ public void GIVEN_CBCEncryptedData_AND_ImprovedClientDecryptingWithForbidEncryptAllowDecrypt_WHEN_Decrypt_THEN_Pass(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ final String objectKey = "test-key-kms-v1-" + language;
+ final String input = "simple-test-input";
+ KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+
+ // Create the object using the old client
+ // V1 Client
+ EncryptionMaterialsProvider materialsProvider = new KMSEncryptionMaterialsProvider(TestUtils.KMS_KEY_ARN);
+
+ CryptoConfiguration v1Config =
+ new CryptoConfiguration(CryptoMode.AuthenticatedEncryption)
+ .withStorageMode(CryptoStorageMode.ObjectMetadata)
+ .withAwsKmsRegion(TestUtils.KMS_REGION);
+
+ AmazonS3Encryption v1Client = AmazonS3EncryptionClient.encryptionBuilder()
+ .withCryptoConfiguration(v1Config)
+ .withEncryptionMaterials(materialsProvider)
+ .build();
+
+ v1Client.putObject(TestUtils.BUCKET, objectKey, input);
+
+ S3ECTestServerClient decClient = TestUtils.testServerClientFor(language);
+ CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyWrappingAlgorithms(true)
+ .build()
+ )
+ .build());
+ String decS3ECId = decClientOutput.getClientId();
+
+ // When: decrypt KC object with a current version client
+ GetObjectOutput output = decClient.getObject(GetObjectInput.builder()
+ .clientID(decS3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+
+ // Then: Pass
+ }
+
+ // Exhaustive test 3
+ // Outcome Version Operation Policy Content Encryption
+ // Pass Improved Decrypt ForbidEncryptAllowDecrypt GCM
+
+ @ParameterizedTest(name = "{displayName} for Encrypt: Java-V1-GCM, Decrypt: {0}")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ public void GIVEN_GCMEncryptedData_AND_ImprovedClientDecryptingWithForbidEncryptAllowDecrypt_WHEN_Decrypt_THEN_Pass(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ final String objectKey = "test-key-kms-v1-gcm-" + language;
+ final String input = "simple-test-input";
+ KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+ CreateClientOutput output1 = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .enableLegacyWrappingAlgorithms(true)
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String s3ECId = output1.getClientId();
+
+ // Create the object using the old client with GCM encryption
+ // V1 Client with GCM
+ EncryptionMaterialsProvider materialsProvider = new KMSEncryptionMaterialsProvider(TestUtils.KMS_KEY_ARN);
+
+ CryptoConfiguration v1Config =
+ new CryptoConfiguration(CryptoMode.StrictAuthenticatedEncryption) // StrictAuthenticatedEncryption uses GCM
+ .withStorageMode(CryptoStorageMode.ObjectMetadata)
+ .withAwsKmsRegion(TestUtils.KMS_REGION);
+
+ AmazonS3Encryption v1Client = AmazonS3EncryptionClient.encryptionBuilder()
+ .withCryptoConfiguration(v1Config)
+ .withEncryptionMaterials(materialsProvider)
+ .build();
+
+ v1Client.putObject(TestUtils.BUCKET, objectKey, input);
+
+ // When: decrypt GCM object with an improved version client
+ GetObjectOutput output = client.getObject(GetObjectInput.builder()
+ .clientID(s3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+
+ // Then: Pass
+ assertEquals(input, new String(output.getBody().array()));
+ }
+
+ // Exhaustive test 4
+ // Outcome Version Operation Policy Content Encryption
+ // Pass Improved Decrypt ForbidEncryptAllowDecrypt KC-GCM
+
+ @ParameterizedTest(name = "{displayName} for Encrypt: {0}, Decrypt: {1}")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#encryptImprovedDecryptImproved")
+ public void GIVEN_KCGCMEncryptedData_AND_ImprovedClientDecryptingWithForbidEncryptAllowDecrypt_WHEN_Decrypt_THEN_Pass(
+ TestUtils.LanguageServerTarget encLang, TestUtils.LanguageServerTarget decLang
+ ) throws Exception {
+
+ S3ECTestServerClient encClient = TestUtils.testServerClientFor(encLang);
+ final String objectKey = "encrypt-kc-gcm-decrypt-improved-test-key-" + encLang + "-" + decLang;
+ final String input = "simple-test-input";
+ KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+ CreateClientOutput encClientOutput = encClient.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .build())
+ .build());
+ String encS3ECId = encClientOutput.getClientId();
+
+ // Given: object encrypted with key commitment
+ encClient.putObject(PutObjectInput.builder()
+ .clientID(encS3ECId)
+ .key(objectKey)
+ .bucket(TestUtils.BUCKET)
+ .body(ByteBuffer.wrap(input.getBytes(StandardCharsets.UTF_8)))
+ .build());
+
+ S3ECTestServerClient decClient = TestUtils.testServerClientFor(decLang);
+ CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String decS3ECId = decClientOutput.getClientId();
+
+ // At high concurrency, this test tends to get:
+ // BadDigest Message: The CRC64NVME you specified did not match the calculated checksum.
+ // I think this is a read after write issue.
+ // A better fix, would be to break this tests suite up into encrypt/decrypt
+ // rather than having a test for many pairs and doing encrypt/decrypt on each pair
+ Thread.sleep(100);
+
+ // When: decrypt KC-GCM object with an improved version client with ForbidEncryptAllowDecrypt policy
+ GetObjectOutput output = decClient.getObject(GetObjectInput.builder()
+ .clientID(decS3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+
+ // Then: Pass
+ assertEquals(input, StandardCharsets.UTF_8.decode(output.getBody()).toString());
+ }
+
+}
diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/GCMTestSuite.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/GCMTestSuite.java
new file mode 100644
index 00000000..ca495f56
--- /dev/null
+++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/GCMTestSuite.java
@@ -0,0 +1,258 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.encryption.s3;
+
+import static software.amazon.encryption.s3.TestUtils.*;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.encryption.s3.client.S3ECTestServerClient;
+import software.amazon.encryption.s3.model.CommitmentPolicy;
+import software.amazon.encryption.s3.model.CreateClientInput;
+import software.amazon.encryption.s3.model.CreateClientOutput;
+import software.amazon.encryption.s3.model.EncryptionAlgorithm;
+import software.amazon.encryption.s3.model.KeyMaterial;
+import software.amazon.encryption.s3.model.S3ECConfig;
+
+/**
+ * GCM Test Suite
+ *
+ * This suite enforces execution order between GCM encrypt and decrypt phases:
+ * 1. EncryptTests - All encrypt tests run in parallel (within this phase)
+ * 2. DecryptTests - Waits for encrypt phase to complete, then all decrypt tests run in parallel
+ *
+ * Coordination is achieved using a CountDownLatch that EncryptTests signals upon completion
+ * and DecryptTests awaits before proceeding.
+ */
+public class GCMTestSuite {
+ // Synchronization latch - released when encrypt phase completes
+ private static final CountDownLatch encryptPhaseComplete = new CountDownLatch(1);
+
+ /**
+ * GCM Encryption Tests - Encrypt Phase
+ *
+ * These tests encrypt objects using GCM (without key commitment) encryption algorithm.
+ * All tests in this class can run in parallel with each other.
+ * The encrypted objects are stored in thread-safe lists for use by DecryptTests.
+ */
+ @Nested
+ @DisplayName("GCMTestSuite - Encrypt")
+ class EncryptTests {
+ private static final String sharedObjectKeyBase = "test-gcm-kms";
+ private static final KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+
+ // Thread-safe list for storing encrypted object keys
+ private static final List crossLanguageObjects =
+ Collections.synchronizedList(new ArrayList<>());
+
+ /**
+ * Public accessor for decrypt tests to retrieve encrypted object keys
+ */
+ static List getCrossLanguageObjects() {
+ return new ArrayList<>(crossLanguageObjects); // Return defensive copy
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with ForbidEncryptAllowDecrypt should encrypt GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_forbid_encrypt_allow_decrypt_should_encrypt_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + language.getLanguageName()),
+ crossLanguageObjects,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Transition configured with the default should encrypt GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#transitionClientsForTest")
+ void transition_configured_with_the_default_should_encrypt_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ // .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + language.getLanguageName()),
+ crossLanguageObjects,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Transition configured with ForbidEncryptAllowDecrypt should encrypt GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#transitionClientsForTest")
+ void transition_configured_with_forbid_encrypt_allow_decrypt_should_encrypt_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + language.getLanguageName()),
+ crossLanguageObjects,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF);
+ }
+
+ @AfterAll
+ static void signalEncryptionComplete() {
+ // Signal that all encryption tests have completed
+ encryptPhaseComplete.countDown();
+ }
+ }
+
+ /**
+ * GCM Decryption Tests - Decrypt Phase
+ *
+ * These tests decrypt objects that were encrypted by EncryptTests.
+ * All tests in this class can run fully in parallel with each other.
+ * They depend on EncryptTests completing first (enforced by @Order).
+ */
+ @Nested
+ @DisplayName("GCMTestSuite - Decrypt")
+ class DecryptTests {
+ private static List crossLanguageObjects;
+ private static final KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+
+ @BeforeAll
+ static void setup() throws InterruptedException {
+ // Wait for all encryption tests to complete
+ encryptPhaseComplete.await();
+
+ // Import encrypted objects from the encrypt phase
+ crossLanguageObjects = EncryptTests.getCrossLanguageObjects();
+
+ // Verify we have objects to decrypt
+ if (crossLanguageObjects.isEmpty()) {
+ throw new IllegalStateException(
+ "No encrypted objects found. Ensure EncryptTests runs first.");
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with ForbidEncryptAllowDecrypt should decrypt GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_forbid_encrypt_allow_decrypt_should_decrypt_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjects,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Transition configured with the default should decrypt GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#transitionClientsForTest")
+ void transition_configured_with_the_default_should_decrypt_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ // .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjects,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Transition configured with ForbidEncryptAllowDecrypt should decrypt GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#transitionClientsForTest")
+ void transition_configured_with_forbid_encrypt_allow_decrypt_should_decrypt_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjects,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptAllowDecrypt should decrypt GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_allow_decrypt_should_decrypt_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjects,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptRequireDecrypt should fail to decrypt GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_require_decrypt_should_fail_to_decrypt_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(client, S3ECId, crossLanguageObjects,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF);
+ }
+ }
+}
diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/InstructionFileFailures.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/InstructionFileFailures.java
new file mode 100644
index 00000000..18ebca47
--- /dev/null
+++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/InstructionFileFailures.java
@@ -0,0 +1,1136 @@
+/*
+* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+* SPDX-License-Identifier: Apache-2.0
+*/
+
+package software.amazon.encryption.s3;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static software.amazon.encryption.s3.TestUtils.*;
+
+import java.nio.ByteBuffer;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.*;
+import java.util.concurrent.CountDownLatch;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import software.amazon.encryption.s3.TestUtils.LanguageServerTarget;
+import software.amazon.encryption.s3.client.S3ECTestServerClient;
+import software.amazon.encryption.s3.model.*;
+
+/**
+* Instruction File Failures Test Suite
+*
+* This suite enforces execution order between encrypt and decrypt phases:
+* 1. EncryptTests - Encrypts objects with various key materials and creates test copies
+* 2. DecryptTests - Waits for encrypt phase to complete, then tests decryption scenarios
+*
+* Coordination is achieved using a CountDownLatch that EncryptTests signals upon completion
+* and DecryptTests awaits before proceeding.
+*
+*/
+public class InstructionFileFailures {
+ // Synchronization latch - released when encrypt phase completes
+ private static final CountDownLatch encryptPhaseComplete = new CountDownLatch(1);
+
+ // Object key suffixes for test copies
+ private static final String SUFFIX_GOOD_COPY = "-good-copy";
+ private static final String SUFFIX_BAD_BOTH_META_AND_INSTRUCTION = "-bad-both-meta-and-instruction";
+ private static final String SUFFIX_BAD_ONLY_INSTRUCTION = "-bad-only-instruction";
+ private static final String SUFFIX_BAD_JSON_INSTRUCTION = "-manipulated-bad-json-instruction";
+ private static final String SUFFIX_MANIPULATED_INSTRUCTION = "-manipuldate-incorrect-key-instruction";
+
+ /**
+ * Encryption Tests - Encrypt Phase
+ *
+ * These tests encrypt objects using various key materials (KMS, RSA, AES) with instruction files.
+ * All tests in this class can run in parallel with each other.
+ * The encrypted objects are stored in thread-safe lists for use by DecryptTests.
+ */
+ @Nested
+ @DisplayName("InstructionFileFailures - Encrypt")
+ class EncryptTests {
+ private static final String sharedObjectKeyBaseMetaDataMode = "test-instruction-files-cases";
+ private static KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+
+ // Thread-safe lists for storing encrypted object keys
+ private static final List crossLanguageObjectsKms =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List crossLanguageObjectsRsa =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List crossLanguageObjectsAes =
+ Collections.synchronizedList(new ArrayList<>());
+
+ // Thread-safe lists for envelope merge tests
+ private static final List crossLanguageObjectsMetadataOnly =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List crossLanguageObjectsInstructionFileDeleted =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List crossLanguageObjectsV3InstructionFileManipulated =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List crossLanguageObjectsV2InstructionFileManipulated =
+ Collections.synchronizedList(new ArrayList<>());
+
+ private static KeyMaterial RSA_KEY;
+ private static KeyMaterial AES_KEY;
+
+ @BeforeAll
+ static void setupKeys() throws Exception {
+ KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
+ keyPairGen.initialize(2048);
+ KeyPair keyPair = keyPairGen.generateKeyPair();
+
+ RSA_KEY = KeyMaterial.builder()
+ .rsaKey(ByteBuffer.wrap(keyPair.getPrivate().getEncoded()))
+ .build();
+
+ KeyGenerator keyGen = KeyGenerator.getInstance("AES");
+ keyGen.init(256);
+ SecretKey aesSecretKey = keyGen.generateKey();
+
+ AES_KEY = KeyMaterial.builder()
+ .aesKey(ByteBuffer.wrap(aesSecretKey.getEncoded()))
+ .build();
+ }
+
+ /**
+ * Public accessors for decrypt tests to retrieve encrypted object keys and key materials
+ */
+ static List getCrossLanguageObjectsKms() {
+ return new ArrayList<>(crossLanguageObjectsKms);
+ }
+
+ static List getCrossLanguageObjectsRsa() {
+ return new ArrayList<>(crossLanguageObjectsRsa);
+ }
+
+ static List getCrossLanguageObjectsAes() {
+ return new ArrayList<>(crossLanguageObjectsAes);
+ }
+
+ static KeyMaterial getRsaKey() {
+ return RSA_KEY;
+ }
+
+ static KeyMaterial getAesKey() {
+ return AES_KEY;
+ }
+
+ static KeyMaterial getKmsKeyArn() {
+ return kmsKeyArn;
+ }
+
+ static List getCrossLanguageObjectsMetadataOnly() {
+ return new ArrayList<>(crossLanguageObjectsMetadataOnly);
+ }
+
+ static List getCrossLanguageObjectsInstructionFileDeleted() {
+ return new ArrayList<>(crossLanguageObjectsInstructionFileDeleted);
+ }
+
+ static List getCrossLanguageObjectsInstructionFileManipulatedV3() {
+ return new ArrayList<>(crossLanguageObjectsV3InstructionFileManipulated);
+ }
+
+ static List getCrossLanguageObjectsInstructionFileManipulatedV2() {
+ return new ArrayList<>(crossLanguageObjectsV2InstructionFileManipulated);
+ }
+
+
+ public static Stream improvedClientsCanPutKMSWithInstructionFile() {
+ return improvedClientsForTest()
+ .filter(target -> !INSTRUCTION_FILE_PUT_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()))
+ .filter(target -> !KMS_INSTRUCTION_FILE_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+ }
+
+ public static Stream improvedClientsCanPutRawRSAWithInstructionFile() {
+ return improvedClientsForTest()
+ .filter(target -> !INSTRUCTION_FILE_PUT_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()))
+ .filter(target -> RAW_RSA_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+ }
+
+ public static Stream improvedClientsCanPutRawAESWithInstructionFile() {
+ return improvedClientsForTest()
+ .filter(target -> !INSTRUCTION_FILE_PUT_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()))
+ .filter(target -> RAW_AES_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt KMS KC-GCM with instruction files")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$EncryptTests#improvedClientsCanPutKMSWithInstructionFile")
+ void encryptWithInstructionFilesKmsKcGcm(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .instructionFileConfig(
+ InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build()
+ )
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + "-kms" + language.getLanguageName()),
+ crossLanguageObjectsKms,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt RSA KC-GCM with instruction files")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$EncryptTests#improvedClientsCanPutRawRSAWithInstructionFile")
+ void encryptWithInstructionFilesRsaKcGcm(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .instructionFileConfig(
+ InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build()
+ )
+ .build())
+ .build());
+
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + "-rsa" + language.getLanguageName()),
+ crossLanguageObjectsRsa,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt AES KC-GCM with instruction files")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$EncryptTests#improvedClientsCanPutRawAESWithInstructionFile")
+ void encryptWithInstructionFilesAesKcGcm(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(AES_KEY)
+ .instructionFileConfig(
+ InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build()
+ )
+ .build())
+ .build());
+
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + "-aes" + language.getLanguageName()),
+ crossLanguageObjectsAes,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt RSA KC-GCM metadata-only for envelope merge test")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$EncryptTests#improvedClientsCanPutRawRSAWithInstructionFile")
+ void encryptMetadataOnlyRsaKcGcm(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ // Encrypt with metadata-only (no instruction file)
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .build())
+ .build());
+
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + "-envelope-merge-metadata-only-" + language.getLanguageName()),
+ crossLanguageObjectsMetadataOnly,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt RSA KC-GCM with instruction file for deletion test")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$EncryptTests#improvedClientsCanPutRawRSAWithInstructionFile")
+ void encryptWithInstructionFileForDeletionRsaKcGcm(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ // Encrypt with instruction file (will be deleted later)
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .instructionFileConfig(
+ InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build()
+ )
+ .build())
+ .build());
+
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + "-envelope-merge-instruction-deleted-" + language.getLanguageName()),
+ crossLanguageObjectsInstructionFileDeleted,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt KMS KC-GCM (V3) with instruction file for manipulation test")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$EncryptTests#improvedClientsCanPutKMSWithInstructionFile")
+ void encryptWithInstructionFileV3ForManipulationKmsKcGcm(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ // Encrypt with instruction file, will be manipulated later on.
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT)
+ .instructionFileConfig(
+ InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build()
+ )
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + "-envelope-manipulation-instruction-" + language.getLanguageName()),
+ crossLanguageObjectsV3InstructionFileManipulated,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt KMS (V2) with instruction file for manipulation test")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$EncryptTests#improvedClientsCanPutKMSWithInstructionFile")
+ void encryptWithInstructionFileV2ForManipulationKms(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ // Encrypt with instruction file, will be manipulated later on.
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .instructionFileConfig(
+ InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build()
+ )
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + "-envelope-manipulation-instruction-" + language.getLanguageName()),
+ crossLanguageObjectsV2InstructionFileManipulated,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF
+ );
+ }
+
+
+ static void makeCopiesToVerifyThings() throws Exception {
+ // Create a plaintext S3 client to copy objects with instruction files
+ try (S3Client ptS3Client = S3Client.create()) {
+ List allCrossLanguageObjects = Stream.of(
+ crossLanguageObjectsKms.stream(),
+ crossLanguageObjectsRsa.stream(),
+ crossLanguageObjectsAes.stream()
+ ).flatMap(s -> s).collect(Collectors.toList());
+ for (String objectKey : allCrossLanguageObjects) {
+ // Get the encrypted object
+ ResponseBytes encryptedObject = ptS3Client.getObjectAsBytes(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+
+ // Get the instruction file
+ String instructionFileKey = objectKey + ".instruction";
+ ResponseBytes instructionFile = ptS3Client.getObjectAsBytes(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(instructionFileKey)
+ .build());
+
+ String instructionFileJson = instructionFile.asUtf8String();
+ Map objectMetadata = encryptedObject.response().metadata();
+
+ // Put a strict copy, to verify that we know how to do this
+ putObjectWithInstructionFile(
+ ptS3Client,
+ objectKey + SUFFIX_GOOD_COPY,
+ encryptedObject.asByteArray(),
+ objectMetadata,
+ instructionFileJson
+ );
+
+ ObjectMapper mapper = new ObjectMapper();
+ Map instructionFileMap = mapper.readValue(instructionFileJson, Map.class);
+
+ instructionFileMap.put("x-amz-c", objectMetadata.get("x-amz-c"));
+ instructionFileMap.put("x-amz-d", objectMetadata.get("x-amz-d"));
+ instructionFileMap.put("x-amz-i", objectMetadata.get("x-amz-i"));
+
+ String instructionFileWithCommitmentValues = mapper.writeValueAsString(instructionFileMap);
+
+ // Put instruction files that should fail:
+ putObjectWithInstructionFile(
+ ptS3Client,
+ objectKey + SUFFIX_BAD_BOTH_META_AND_INSTRUCTION,
+ encryptedObject.asByteArray(),
+ objectMetadata,
+ instructionFileWithCommitmentValues
+ );
+
+ putObjectWithInstructionFile(
+ ptS3Client,
+ objectKey + SUFFIX_BAD_ONLY_INSTRUCTION,
+ encryptedObject.asByteArray(),
+ Map.of(),
+ instructionFileWithCommitmentValues
+ );
+
+ }
+
+ // Delete instruction files for envelope merge tests
+ for (String objectKey : crossLanguageObjectsInstructionFileDeleted) {
+ String instructionFileKey = objectKey + ".instruction";
+ try {
+ ptS3Client.deleteObject(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(instructionFileKey)
+ .build());
+ } catch (Exception e) {
+ // Ignore if file doesn't exist
+ }
+ }
+
+ // manipulate V3 instruction files
+ for (String objectKey: crossLanguageObjectsV3InstructionFileManipulated) {
+ // Get the encrypted object
+ ResponseBytes encryptedObject = ptS3Client.getObjectAsBytes(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+
+ // Get the instruction file
+ String instructionFileKey = objectKey + ".instruction";
+ ResponseBytes instructionFile = ptS3Client.getObjectAsBytes(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(instructionFileKey)
+ .build());
+
+ String instructionFileJson = instructionFile.asUtf8String();
+ Map objectMetadata = encryptedObject.response().metadata();
+
+ ObjectMapper mapper = new ObjectMapper();
+
+ Map invalidInstructionFileMap = new HashMap<>();
+ invalidInstructionFileMap.put("invalid", "json");
+
+ String invalidInstructionFile = mapper.writeValueAsString(invalidInstructionFileMap);
+
+ // Put instruction files that should fail:
+ putObjectWithInstructionFile(
+ ptS3Client,
+ objectKey + SUFFIX_BAD_JSON_INSTRUCTION + "-v3",
+ encryptedObject.asByteArray(),
+ objectMetadata,
+ invalidInstructionFile
+ );
+ }
+
+ // manipulate V2 instruction files
+ for (String objectKey: crossLanguageObjectsV2InstructionFileManipulated) {
+ // Get the encrypted object
+ ResponseBytes encryptedObject = ptS3Client.getObjectAsBytes(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+
+ // Get the instruction file
+ String instructionFileKey = objectKey + ".instruction";
+ ResponseBytes instructionFile = ptS3Client.getObjectAsBytes(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(instructionFileKey)
+ .build());
+
+ String instructionFileJson = instructionFile.asUtf8String();
+ Map objectMetadata = encryptedObject.response().metadata();
+
+ ObjectMapper mapper = new ObjectMapper();
+ Map instructionFileMap = mapper.readValue(instructionFileJson, Map.class);
+
+ instructionFileMap.put("x-amz-key-v2-tampered", instructionFileMap.get("x-amz-key-v2"));
+ instructionFileMap.remove("x-amz-key-v2");
+
+ String badKeyInstructionFile = mapper.writeValueAsString(instructionFileMap);
+
+ // Put instruction files that should fail:
+ putObjectWithInstructionFile(
+ ptS3Client,
+ objectKey + SUFFIX_MANIPULATED_INSTRUCTION + "-v2",
+ encryptedObject.asByteArray(),
+ objectMetadata,
+ badKeyInstructionFile
+ );
+ }
+ }
+ }
+
+ static void putObjectWithInstructionFile(
+ S3Client ptS3Client,
+ String newObjectKey,
+ byte[] objectData,
+ Map objectMetadata,
+ String instructionFileJson
+ ) {
+
+ // Put the encrypted object copy
+ ptS3Client.putObject(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(newObjectKey)
+ .metadata(objectMetadata)
+ .build(),
+ software.amazon.awssdk.core.sync.RequestBody.fromBytes(objectData));
+
+ // Put the instruction file copy
+ ptS3Client.putObject(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(newObjectKey + ".instruction")
+ .build(),
+ software.amazon.awssdk.core.sync.RequestBody.fromBytes(instructionFileJson.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
+ }
+
+ @AfterAll
+ static void signalEncryptionComplete() throws Exception {
+ makeCopiesToVerifyThings();
+
+ // Signal that all encryption tests have completed
+ encryptPhaseComplete.countDown();
+ }
+ }
+
+ /**
+ * Decryption Tests - Decrypt Phase
+ *
+ * These tests decrypt objects that were encrypted by EncryptTests.
+ * All tests in this class can run fully in parallel with each other.
+ * They depend on EncryptTests completing first.
+ */
+ @Nested
+ @DisplayName("InstructionFileFailures - Decrypt")
+ class DecryptTests {
+ private static List crossLanguageObjectsKms;
+ private static List crossLanguageObjectsRsa;
+ private static List crossLanguageObjectsAes;
+ private static List crossLanguageObjectsMetadataOnly;
+ private static List crossLanguageObjectsInstructionFileDeleted;
+ private static List crossLanguageObjectsInstructionFileManipulatedV3;
+ private static List crossLanguageObjectsInstructionFileManipulatedV2;
+ private static KeyMaterial kmsKeyArn;
+ private static KeyMaterial RSA_KEY;
+ private static KeyMaterial AES_KEY;
+
+ @BeforeAll
+ static void setup() throws InterruptedException {
+ // Wait for all encryption tests to complete
+ encryptPhaseComplete.await();
+
+ // Import encrypted objects and key materials from the encrypt phase
+ crossLanguageObjectsKms = EncryptTests.getCrossLanguageObjectsKms();
+ crossLanguageObjectsRsa = EncryptTests.getCrossLanguageObjectsRsa();
+ crossLanguageObjectsAes = EncryptTests.getCrossLanguageObjectsAes();
+ crossLanguageObjectsMetadataOnly = EncryptTests.getCrossLanguageObjectsMetadataOnly();
+ crossLanguageObjectsInstructionFileDeleted = EncryptTests.getCrossLanguageObjectsInstructionFileDeleted();
+ crossLanguageObjectsInstructionFileManipulatedV3 = EncryptTests.getCrossLanguageObjectsInstructionFileManipulatedV3();
+ crossLanguageObjectsInstructionFileManipulatedV2 = EncryptTests.getCrossLanguageObjectsInstructionFileManipulatedV2();
+ kmsKeyArn = EncryptTests.getKmsKeyArn();
+ RSA_KEY = EncryptTests.getRsaKey();
+ AES_KEY = EncryptTests.getAesKey();
+
+ // Verify we have objects to decrypt
+ if (crossLanguageObjectsKms.isEmpty() && crossLanguageObjectsRsa.isEmpty() && crossLanguageObjectsAes.isEmpty()) {
+ throw new IllegalStateException(
+ "No encrypted objects found. Ensure EncryptTests runs first.");
+ }
+ }
+
+ public static Stream clientsCanGetKMSWithInstructionFile() {
+ Stream improved = improvedClientsForTest()
+ .filter(target -> !KMS_INSTRUCTION_FILE_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+
+ Stream transition = transitionClientsForTest()
+ .filter(target -> !KMS_INSTRUCTION_FILE_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+
+ return Stream.concat(improved, transition);
+ }
+
+ public static Stream clientsCanGetRawRSAWithInstructionFile() {
+ Stream improved = improvedClientsForTest()
+ .filter(target -> RAW_RSA_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+
+ Stream transition = transitionClientsForTest()
+ .filter(target -> RAW_RSA_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+
+ return Stream.concat(improved, transition);
+ }
+
+ public static Stream clientsCanGetRawAESWithInstructionFile() {
+ Stream improved = improvedClientsForTest()
+ .filter(target -> RAW_AES_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+
+ Stream transition = transitionClientsForTest()
+ .filter(target -> RAW_AES_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+
+ return Stream.concat(improved, transition);
+ }
+
+ // KMS instruction files decrypt
+
+ @ParameterizedTest(name = "{0}: Successfully decrypt KMS encrypted original and good-copy objects")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetKMSWithInstructionFile")
+ void decryptKmsOriginalAndGoodCopyObjectsSucceeds(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(
+ client,
+ S3ECId,
+ crossLanguageObjectsKms,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+
+ TestUtils.Decrypt(
+ client,
+ S3ECId,
+ crossLanguageObjectsKms
+ .stream()
+ .map(key -> key + SUFFIX_GOOD_COPY)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ crossLanguageObjectsKms
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt KMS when commitment is duplicated in metadata and instruction file")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetKMSWithInstructionFile")
+ void decryptKmsWithDuplicateCommitmentInMetadataAndInstructionFails(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsKms
+ .stream()
+ .map(key -> key + SUFFIX_BAD_BOTH_META_AND_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt KMS when commitment is only in instruction file")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetKMSWithInstructionFile")
+ void decryptKmsWithCommitmentOnlyInInstructionFileFails(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsKms
+ .stream()
+ .map(key -> key + SUFFIX_BAD_ONLY_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt KMS duplicate commitment with FORBID_ENCRYPT_ALLOW_DECRYPT policy")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetKMSWithInstructionFile")
+ void decryptKmsWithDuplicateCommitmentFailsWithForbidPolicy(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsKms
+ .stream()
+ .map(key -> key + SUFFIX_BAD_BOTH_META_AND_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt KMS instruction file commitment with FORBID_ENCRYPT_ALLOW_DECRYPT policy")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetKMSWithInstructionFile")
+ void decryptKmsWithInstructionFileCommitmentFailsWithForbidPolicy(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsKms
+ .stream()
+ .map(key -> key + SUFFIX_BAD_ONLY_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ // RSA instruction file decrypt
+
+ @ParameterizedTest(name = "{0}: Successfully decrypt RSA encrypted original and good-copy objects")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawRSAWithInstructionFile")
+ void decryptRsaOriginalAndGoodCopyObjectsSucceeds(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(
+ client,
+ S3ECId,
+ crossLanguageObjectsRsa,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+
+ TestUtils.Decrypt(
+ client,
+ S3ECId,
+ crossLanguageObjectsRsa
+ .stream()
+ .map(key -> key + SUFFIX_GOOD_COPY)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ crossLanguageObjectsRsa
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt RSA when commitment is duplicated in metadata and instruction file")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawRSAWithInstructionFile")
+ void decryptRsaWithDuplicateCommitmentInMetadataAndInstructionFails(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsRsa
+ .stream()
+ .map(key -> key + SUFFIX_BAD_BOTH_META_AND_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt RSA when commitment is only in instruction file")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawRSAWithInstructionFile")
+ void decryptRsaWithCommitmentOnlyInInstructionFileFails(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsRsa
+ .stream()
+ .map(key -> key + SUFFIX_BAD_ONLY_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt RSA duplicate commitment with FORBID_ENCRYPT_ALLOW_DECRYPT policy")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawRSAWithInstructionFile")
+ void decryptRsaWithDuplicateCommitmentFailsWithForbidPolicy(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsRsa
+ .stream()
+ .map(key -> key + SUFFIX_BAD_BOTH_META_AND_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt RSA instruction file commitment with FORBID_ENCRYPT_ALLOW_DECRYPT policy")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawRSAWithInstructionFile")
+ void decryptRsaWithInstructionFileCommitmentFailsWithForbidPolicy(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsRsa
+ .stream()
+ .map(key -> key + SUFFIX_BAD_ONLY_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ // AES instruction file decrypt
+
+ @ParameterizedTest(name = "{0}: Successfully decrypt AES encrypted original and good-copy objects")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawAESWithInstructionFile")
+ void decryptAesOriginalAndGoodCopyObjectsSucceeds(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(AES_KEY)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(
+ client,
+ S3ECId,
+ crossLanguageObjectsAes,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+
+ TestUtils.Decrypt(
+ client,
+ S3ECId,
+ crossLanguageObjectsAes
+ .stream()
+ .map(key -> key + SUFFIX_GOOD_COPY)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY,
+ crossLanguageObjectsAes
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt AES when commitment is duplicated in metadata and instruction file")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawAESWithInstructionFile")
+ void decryptAesWithDuplicateCommitmentInMetadataAndInstructionFails(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(AES_KEY)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsAes
+ .stream()
+ .map(key -> key + SUFFIX_BAD_BOTH_META_AND_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt AES when commitment is only in instruction file")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawAESWithInstructionFile")
+ void decryptAesWithCommitmentOnlyInInstructionFileFails(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(AES_KEY)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsAes
+ .stream()
+ .map(key -> key + SUFFIX_BAD_ONLY_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt AES duplicate commitment with FORBID_ENCRYPT_ALLOW_DECRYPT policy")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawAESWithInstructionFile")
+ void decryptAesWithDuplicateCommitmentFailsWithForbidPolicy(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(AES_KEY)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsAes
+ .stream()
+ .map(key -> key + SUFFIX_BAD_BOTH_META_AND_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt AES instruction file commitment with FORBID_ENCRYPT_ALLOW_DECRYPT policy")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawAESWithInstructionFile")
+ void decryptAesWithInstructionFileCommitmentFailsWithForbidPolicy(TestUtils.LanguageServerTarget language) {
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(AES_KEY)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsAes
+ .stream()
+ .map(key -> key + SUFFIX_BAD_ONLY_INSTRUCTION)
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ // Envelope merge tests
+
+ @ParameterizedTest(name = "{0}: Successfully decrypt metadata-only object with instruction file config")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawRSAWithInstructionFile")
+ void decryptMetadataOnlyObjectWithInstructionFileConfigSucceeds(TestUtils.LanguageServerTarget language) {
+ if (crossLanguageObjectsMetadataOnly.isEmpty()) return;
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ // Configure client to look for instruction file but metadata has complete envelope
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .instructionFileConfig(
+ InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build()
+ )
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // Should succeed - instruction file doesn't exist but metadata has complete envelope
+ TestUtils.Decrypt(
+ client,
+ S3ECId,
+ crossLanguageObjectsMetadataOnly,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt when metadata incomplete and instruction file deleted")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawRSAWithInstructionFile")
+ void decryptWithIncompleteMetadataAndNoInstructionFileFails(TestUtils.LanguageServerTarget language) {
+ if (crossLanguageObjectsInstructionFileDeleted.isEmpty()) return;
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ // Configure client for metadata-only but metadata is incomplete
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // Should fail - metadata incomplete (missing x-amz-3, x-amz-w), instruction file deleted
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsInstructionFileDeleted,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt with instruction file config when file deleted and metadata incomplete")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetRawRSAWithInstructionFile")
+ void decryptWithInstructionFileConfigWhenFileDeletedFails(TestUtils.LanguageServerTarget language) {
+ if (crossLanguageObjectsInstructionFileDeleted.isEmpty()) return;
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ // Configure client to look for instruction file but it's been deleted
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(RSA_KEY)
+ .instructionFileConfig(
+ InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build()
+ )
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // Should fail - instruction file deleted, metadata incomplete (missing x-amz-3, x-amz-w)
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsInstructionFileDeleted,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt with manipulated V3 Instruction File")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetKMSWithInstructionFile")
+ void decryptWithManipulatedInstructionFileV3ImprovedClients(TestUtils.LanguageServerTarget language) {
+ if (TRANSITION_VERSIONS.contains(language.getLanguageName())) {
+ return;
+ }
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsInstructionFileManipulatedV3
+ .stream()
+ .map(key -> key + SUFFIX_BAD_JSON_INSTRUCTION + "-v3")
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to decrypt with manipulated V2 Instruction File")
+ @MethodSource("software.amazon.encryption.s3.InstructionFileFailures$DecryptTests#clientsCanGetKMSWithInstructionFile")
+ void decryptWithManipulatedInstructionFileV2ImprovedClients(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt_fails(
+ client,
+ S3ECId,
+ crossLanguageObjectsInstructionFileManipulatedV2
+ .stream()
+ .map(key -> key + SUFFIX_MANIPULATED_INSTRUCTION + "-v2")
+ .collect(Collectors.toList()),
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF
+ );
+ }
+ }
+}
diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/KC_GCMTestSuite.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/KC_GCMTestSuite.java
new file mode 100644
index 00000000..d256f909
--- /dev/null
+++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/KC_GCMTestSuite.java
@@ -0,0 +1,391 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.encryption.s3;
+
+import static software.amazon.encryption.s3.TestUtils.*;
+
+import java.nio.ByteBuffer;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.opentest4j.TestAbortedException;
+import software.amazon.encryption.s3.client.S3ECTestServerClient;
+import software.amazon.encryption.s3.model.CommitmentPolicy;
+import software.amazon.encryption.s3.model.CreateClientInput;
+import software.amazon.encryption.s3.model.CreateClientOutput;
+import software.amazon.encryption.s3.model.EncryptionAlgorithm;
+import software.amazon.encryption.s3.model.InstructionFileConfig;
+import software.amazon.encryption.s3.model.KeyMaterial;
+import software.amazon.encryption.s3.model.S3ECConfig;
+
+/**
+ * KC-GCM Test Suite
+ *
+ * This suite enforces execution order between KC-GCM encrypt and decrypt phases:
+ * 1. EncryptTests - All encrypt tests run in parallel (within this phase)
+ * 2. DecryptTests - Waits for encrypt phase to complete, then all decrypt tests run in parallel
+ *
+ * Coordination is achieved using a CountDownLatch that EncryptTests signals upon completion
+ * and DecryptTests awaits before proceeding.
+ */
+public class KC_GCMTestSuite {
+ // Synchronization latch - released when encrypt phase completes
+ private static final CountDownLatch encryptPhaseComplete = new CountDownLatch(1);
+
+ /**
+ * KC-GCM Encryption Tests - Encrypt Phase
+ *
+ * These tests encrypt objects using Key Commitment GCM encryption algorithm.
+ * All tests in this class can run in parallel with each other.
+ * The encrypted objects are stored in thread-safe lists for use by DecryptTests.
+ */
+ @Nested
+ @DisplayName("KC_GCMTestSuite - Encrypt")
+ class EncryptTests {
+ private static final String sharedObjectKeyBaseMetaDataMode = "test-kc-gcm-kms";
+ private static final String sharedObjectKeyBaseInsFileMode = "test-kc-gcm-rsa-instruction-file";
+ private static final KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+
+ // Thread-safe lists for storing encrypted object keys
+ private static final List crossLanguageObjectsMetaDataMode =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List crossLanguageObjectsInstructionFiles =
+ Collections.synchronizedList(new ArrayList<>());
+
+ private static KeyPair RSA_KEY_PAIR_1;
+
+ @BeforeAll
+ static void setupKeys() throws Exception {
+ KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
+ keyPairGen.initialize(2048);
+ RSA_KEY_PAIR_1 = keyPairGen.generateKeyPair();
+ }
+
+ /**
+ * Public accessors for decrypt tests to retrieve encrypted object keys and RSA key
+ */
+ static List getCrossLanguageObjectsMetaDataMode() {
+ return new ArrayList<>(crossLanguageObjectsMetaDataMode);
+ }
+
+ static List getCrossLanguageObjectsInstructionFiles() {
+ return new ArrayList<>(crossLanguageObjectsInstructionFiles);
+ }
+
+ static KeyPair getRsaKeyPair() {
+ return RSA_KEY_PAIR_1;
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptAllowDecrypt should encrypt KC-GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_allow_decrypt_should_encrypt_kc_gcm_kms(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + language.getLanguageName()),
+ crossLanguageObjectsMetaDataMode,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptRequireDecrypt should encrypt KC-GCM (instruction file)")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_require_decrypt_should_encrypt_kc_gcm_ins_file_rsa(
+ TestUtils.LanguageServerTarget language
+ ) {
+ if (!RAW_SUPPORTED.contains(language.getLanguageName())) {
+ throw new TestAbortedException("Not encrypting raw keyring with: " + language.getLanguageName());
+ }
+
+ KeyMaterial rsaKey = KeyMaterial.builder()
+ .rsaKey(ByteBuffer.wrap(RSA_KEY_PAIR_1.getPrivate().getEncoded()))
+ .build();
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .keyMaterial(rsaKey).build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseInsFileMode + language.getLanguageName()),
+ crossLanguageObjectsInstructionFiles,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptRequireDecrypt should encrypt KC-GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_require_decrypt_should_encrypt_kc_gcm_kms(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + language.getLanguageName()),
+ crossLanguageObjectsMetaDataMode,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with the default should encrypt KC-GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_the_default_should_encrypt_kc_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ // .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBaseMetaDataMode + language.getLanguageName()),
+ crossLanguageObjectsMetaDataMode,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @AfterAll
+ static void signalEncryptionComplete() {
+ // Signal that all encryption tests have completed
+ encryptPhaseComplete.countDown();
+ }
+ }
+
+ /**
+ * KC-GCM Decryption Tests - Decrypt Phase
+ *
+ * These tests decrypt objects that were encrypted by EncryptTests.
+ * All tests in this class can run fully in parallel with each other.
+ * They depend on EncryptTests completing first (enforced by @Order).
+ */
+ @Nested
+ @DisplayName("KC_GCMTestSuite - Decrypt")
+ class DecryptTests {
+ private static List crossLanguageObjectsMetaDataMode;
+ private static List crossLanguageObjectsInstructionFiles;
+ private static KeyPair RSA_KEY_PAIR_1;
+ private static final KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+
+ @BeforeAll
+ static void setup() throws InterruptedException {
+ // Wait for all encryption tests to complete
+ encryptPhaseComplete.await();
+
+ // Import encrypted objects and RSA key from the encrypt phase
+ crossLanguageObjectsMetaDataMode = EncryptTests.getCrossLanguageObjectsMetaDataMode();
+ crossLanguageObjectsInstructionFiles = EncryptTests.getCrossLanguageObjectsInstructionFiles();
+ RSA_KEY_PAIR_1 = EncryptTests.getRsaKeyPair();
+
+ // Verify we have objects to decrypt
+ if (crossLanguageObjectsMetaDataMode.isEmpty()) {
+ throw new IllegalStateException(
+ "No encrypted objects found. Ensure EncryptTests runs first.");
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: Transition configured with the default should decrypt KC-GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#transitionClientsForTest")
+ void transition_configured_with_the_default_should_decrypt_kc_gcm_kms(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjectsMetaDataMode,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Transition configured with ForbidEncryptAllowDecrypt should decrypt KC-GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#transitionClientsForTest")
+ void transition_configured_with_forbid_encrypt_allow_decrypt_should_decrypt_kc_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjectsMetaDataMode,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with ForbidEncryptAllowDecrypt should decrypt KC-GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_forbid_encrypt_allow_decrypt_should_decrypt_kc_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjectsMetaDataMode,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptAllowDecrypt should decrypt KC-GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_allow_decrypt_should_decrypt_kc_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjectsMetaDataMode,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptRequireDecrypt should decrypt KC-GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_require_decrypt_should_decrypt_kc_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjectsMetaDataMode,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with the default should decrypt KC-GCM")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_the_default_should_decrypt_kc_gcm(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ // .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjectsMetaDataMode,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Improved configured with RequireEncryptRequireDecrypt should decrypt KC-GCM (instruction file)")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#improvedClientsForTest")
+ void improved_configured_with_require_encrypt_require_decrypt_should_decrypt_kc_gcm_ins_file(
+ final TestUtils.LanguageServerTarget language
+ ) {
+ if (!RAW_SUPPORTED.contains(language.getLanguageName())) {
+ throw new TestAbortedException("Not encrypting raw keyring with: " + language.getLanguageName());
+ }
+
+ KeyMaterial rsaKey = KeyMaterial.builder()
+ .rsaKey(ByteBuffer.wrap(RSA_KEY_PAIR_1.getPrivate().getEncoded()))
+ .build();
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .keyMaterial(rsaKey).build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjectsInstructionFiles,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Transition configured with default should decrypt KC-GCM (instruction file)")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#transitionClientsForTest")
+ void transition_configured_with_require_encrypt_require_decrypt_should_decrypt_kc_gcm_ins_file_rsa(
+ final TestUtils.LanguageServerTarget language
+ ) {
+ if (!RAW_SUPPORTED.contains(language.getLanguageName())) {
+ throw new TestAbortedException("Not encrypting raw keyring with: " + language.getLanguageName());
+ }
+
+ KeyMaterial rsaKey = KeyMaterial.builder()
+ .rsaKey(ByteBuffer.wrap(RSA_KEY_PAIR_1.getPrivate().getEncoded()))
+ .build();
+
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .keyMaterial(rsaKey).build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Decrypt(client, S3ECId, crossLanguageObjectsInstructionFiles,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ }
+}
diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/KeyCommitmentPolicyEncryptFailureTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/KeyCommitmentPolicyEncryptFailureTests.java
new file mode 100644
index 00000000..f705f89d
--- /dev/null
+++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/KeyCommitmentPolicyEncryptFailureTests.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.encryption.s3;
+
+import static software.amazon.encryption.s3.TestUtils.*;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import software.amazon.encryption.s3.client.S3ECTestServerClient;
+import software.amazon.encryption.s3.model.CommitmentPolicy;
+import software.amazon.encryption.s3.model.EncryptionAlgorithm;
+import software.amazon.encryption.s3.model.KeyMaterial;
+import software.amazon.encryption.s3.model.S3ECConfig;
+
+/**
+ * Key Commitment Policy — Encryption Failure Tests
+ *
+ * Per the specification (key-commitment.md):
+ * "When the commitment policy is REQUIRE_ENCRYPT_ALLOW_DECRYPT,
+ * the S3EC MUST only encrypt using an algorithm suite which supports key commitment."
+ * "When the commitment policy is REQUIRE_ENCRYPT_REQUIRE_DECRYPT,
+ * the S3EC MUST only encrypt using an algorithm suite which supports key commitment."
+ * "When the commitment policy is FORBID_ENCRYPT_ALLOW_DECRYPT,
+ * the S3EC MUST NOT encrypt using an algorithm suite which supports key commitment."
+ *
+ * These tests verify that attempting to encrypt with an algorithm that conflicts
+ * with the commitment policy is rejected by the S3EC — either at client creation
+ * or at PutObject time.
+ *
+ * Currently scoped to Python V4 only. Other languages can be enabled by
+ * switching the MethodSource to a broader provider (e.g. improvedClientsForTest).
+ */
+@DisplayName("Key Commitment Policy — Encrypt Failures")
+public class KeyCommitmentPolicyEncryptFailureTests {
+
+ private static final KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+
+ @ParameterizedTest(name = "{0}: REQUIRE_ENCRYPT_ALLOW_DECRYPT with non-committing GCM MUST fail to encrypt")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#pythonV4ClientForTest")
+ void require_encrypt_allow_decrypt_with_non_committing_gcm_must_fail(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ S3ECConfig config = S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build();
+
+ TestUtils.Encrypt_fails(client, config,
+ appendTestSuffix("test-kc-policy-fail-REAC-gcm-" + language.getLanguageName()));
+ }
+
+ @ParameterizedTest(name = "{0}: REQUIRE_ENCRYPT_REQUIRE_DECRYPT with non-committing GCM MUST fail to encrypt")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#pythonV4ClientForTest")
+ void require_encrypt_require_decrypt_with_non_committing_gcm_must_fail(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ S3ECConfig config = S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build();
+
+ TestUtils.Encrypt_fails(client, config,
+ appendTestSuffix("test-kc-policy-fail-RERD-gcm-" + language.getLanguageName()));
+ }
+
+ @ParameterizedTest(name = "{0}: FORBID_ENCRYPT_ALLOW_DECRYPT with committing GCM MUST fail to encrypt")
+ @MethodSource("software.amazon.encryption.s3.TestUtils#pythonV4ClientForTest")
+ void forbid_encrypt_allow_decrypt_with_committing_gcm_must_fail(
+ TestUtils.LanguageServerTarget language
+ ) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ S3ECConfig config = S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY)
+ .build();
+
+ TestUtils.Encrypt_fails(client, config,
+ appendTestSuffix("test-kc-policy-fail-FEAD-kc-gcm-" + language.getLanguageName()));
+ }
+}
diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RangedGetTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RangedGetTests.java
new file mode 100644
index 00000000..5a954e1f
--- /dev/null
+++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RangedGetTests.java
@@ -0,0 +1,1687 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.encryption.s3;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static software.amazon.encryption.s3.TestUtils.*;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.CountDownLatch;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.GetObjectResponse;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.amazonaws.services.s3.AmazonS3Encryption;
+import com.amazonaws.services.s3.AmazonS3EncryptionClient;
+import com.amazonaws.services.s3.model.CryptoConfiguration;
+import com.amazonaws.services.s3.model.CryptoMode;
+import com.amazonaws.services.s3.model.CryptoStorageMode;
+import com.amazonaws.services.s3.model.EncryptionMaterialsProvider;
+import com.amazonaws.services.s3.model.KMSEncryptionMaterialsProvider;
+
+import software.amazon.encryption.s3.TestUtils.LanguageServerTarget;
+import software.amazon.encryption.s3.client.S3ECTestServerClient;
+import software.amazon.encryption.s3.model.CommitmentPolicy;
+import software.amazon.encryption.s3.model.CreateClientInput;
+import software.amazon.encryption.s3.model.CreateClientOutput;
+import software.amazon.encryption.s3.model.EncryptionAlgorithm;
+import software.amazon.encryption.s3.model.InstructionFileConfig;
+import software.amazon.encryption.s3.model.GetObjectInput;
+import software.amazon.encryption.s3.model.GetObjectOutput;
+import software.amazon.encryption.s3.model.KeyMaterial;
+import software.amazon.encryption.s3.model.S3ECConfig;
+
+/**
+ * Ranged Get Tests - S3 Encryption Client Cross-Language Compatibility
+ *
+ * PURPOSE:
+ * This test suite validates that ranged get operations (partial object reads) work correctly
+ * across all three encryption algorithms (CBC, GCM, KC-GCM) and that commitment validation
+ * occurs properly during ranged gets for KC-GCM encrypted objects.
+ *
+ * WHAT IS BEING TESTED:
+ * 1. Ranged gets successfully retrieve partial content from encrypted objects across all algorithms
+ * 2. Commitment validation is enforced during ranged gets for KC-GCM encrypted objects
+ * 3. Corrupted commitment metadata (removed, moved, or mutated) causes ranged gets to fail
+ * 4. Various byte ranges work correctly: start, end, middle, whole file, and auth tag only
+ *
+ * WHY THIS IS IMPORTANT:
+ * - Ranged gets are a critical S3 feature that must work with encrypted objects
+ * - KC-GCM's commitment mechanism must be validated even for partial reads to prevent
+ * commitment-based issues where an actor control the encryption keys
+ * - Cross-language compatibility ensures all SDKs handle ranged gets consistently
+ * - Edge cases (first/last bytes, auth tags) verify boundary condition handling
+ *
+ * TEST STRUCTURE:
+ * This suite uses a two-phase approach with enforced ordering:
+ * 1. EncryptTests - Encrypts objects with CBC, GCM, and KC-GCM algorithms
+ * - Creates corrupted KC-GCM test cases with manipulated commitment metadata
+ * - All encrypt tests can run in parallel within this phase
+ * 2. RangedGetTests - Waits for encryption to complete, then tests ranged gets
+ * - Tests successful ranged gets on valid objects
+ * - Tests failed ranged gets on corrupted commitment objects
+ * - All ranged get tests can run in parallel within this phase
+ *
+ * Coordination uses a CountDownLatch to ensure all encryption completes before ranged gets begin.
+ *
+ * INPUT DIMENSIONS:
+ * - Encryption Algorithm: CBC, GCM, KC-GCM
+ * - Language Implementation: All languages supporting RANGED_GETS_SUPPORTED
+ * - Byte Range Types:
+ * * Start (bytes 0-99)
+ * * End (last 100 bytes)
+ * * Middle (100 bytes centered in file)
+ * * Whole file (all bytes)
+ * * Auth tag only (last 16 bytes for authenticated algorithms)
+ * - Storage Mode (KC-GCM only):
+ * * Object Metadata Storage (all metadata in object, no instruction file)
+ * * Instruction File Storage (c/d/i in metadata, x-amz-3/w/m/t in instruction file)
+ * - Commitment State (KC-GCM only):
+ * * Valid - Object Metadata Storage (original and good-copy)
+ * * Valid - Instruction File Storage (original and good-copy)
+ * * Corrupted - Object Metadata Storage:
+ * - Mutated c/d/i: bit flipped in metadata values
+ * - Invalid c length: c < 28 bytes in metadata
+ * - Invalid c length: c > 28 bytes in metadata
+ * * Corrupted - Instruction File Storage:
+ * - Commitment duplicated: c/d/i in instruction file (already in metadata)
+ * - Commitment removed: c/d/i removed from metadata
+ * - Mutated c/d/i in metadata: bit flipped
+ * - Mutated c/d/i in instruction file: bit flipped
+ * - Invalid c length: c < 28 bytes in metadata
+ * - Invalid c length: c > 28 bytes in metadata
+ *
+ * EXPECTED RESULTS:
+ * - Positive: Ranged gets on valid CBC, GCM, KC-GCM objects return correct partial content
+ * - Negative: Ranged gets on corrupted KC-GCM objects fail with commitment validation errors
+ *
+ * REPRESENTATIVE VALUES:
+ * - Bit flip position: Randomly selected per test run, included in object key name
+ * - File size: Object keys themselves (short strings) serve as representative small files
+ * - Byte ranges: Fixed patterns covering important boundary conditions
+ *
+ * SCOPE:
+ * - Languages in RANGED_GETS_SUPPORTED set are tested,
+ * the encrypt tests are to create values that are then tested.
+ * - CBC and GCM tests validate ranged get functionality works
+ * - KC-GCM tests focus on commitment validation during ranged gets
+ */
+public class RangedGetTests {
+ // Synchronization latch - released when encrypt phase completes
+ private static final CountDownLatch encryptPhaseComplete = new CountDownLatch(1);
+
+ // Random number generator for bit flipping (seeded for reproducibility)
+ private static final Random random = new Random(System.currentTimeMillis());
+
+ // Object key suffixes for test copies
+ private static final String SUFFIX_GOOD_COPY = "-good-copy";
+ private static final String SUFFIX_BAD_MUTATED_C = "-bad-mutated-c-bit-";
+ private static final String SUFFIX_BAD_MUTATED_D = "-bad-mutated-d-bit-";
+ private static final String SUFFIX_BAD_MUTATED_I = "-bad-mutated-i-bit-";
+ private static final String SUFFIX_BAD_INVALID_D_LENGTH_SHORT = "-bad-invalid-d-length-short";
+ private static final String SUFFIX_BAD_INVALID_D_LENGTH_LONG = "-bad-invalid-d-length-long";
+ private static final String SUFFIX_BAD_COMMITMENT_IN_INSTRUCTION = "-bad-commitment-in-instruction";
+
+ /**
+ * Encryption Tests - Encrypt Phase
+ *
+ * These tests encrypt objects using CBC, GCM, and KC-GCM algorithms, then create
+ * corrupted copies for failure testing. All tests in this class can run in parallel.
+ */
+ @Nested
+ @DisplayName("RangedGetTests - Encrypt")
+ class EncryptTests {
+ private static final String sharedObjectKeyBase = "test-ranged-get";
+ private static KeyMaterial kmsKeyArn = KeyMaterial.builder()
+ .kmsKeyId(TestUtils.KMS_KEY_ARN)
+ .build();
+
+ // Thread-safe lists for storing encrypted object keys
+ private static final List cbcObjects =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List gcmObjects =
+ Collections.synchronizedList(new ArrayList<>());
+ // KC-GCM with Object Metadata Storage (all metadata in object)
+ private static final List kcGcmObjectsMetadata =
+ Collections.synchronizedList(new ArrayList<>());
+ // KC-GCM with Instruction File Storage (c/d/i in metadata, rest in instruction file)
+ private static final List kcGcmObjectsInstruction =
+ Collections.synchronizedList(new ArrayList<>());
+ // Corruption test lists for metadata storage mode
+ private static final List mutatedCObjectsMetadata =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List mutatedDObjectsMetadata =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List mutatedIObjectsMetadata =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List invalidDLengthShortMetadata =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List invalidDLengthLongMetadata =
+ Collections.synchronizedList(new ArrayList<>());
+ // Corruption test lists for instruction file storage mode
+ private static final List mutatedCObjectsInstruction =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List mutatedDObjectsInstruction =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List mutatedIObjectsInstruction =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List invalidDLengthShortInstruction =
+ Collections.synchronizedList(new ArrayList<>());
+ private static final List invalidDLengthLongInstruction =
+ Collections.synchronizedList(new ArrayList<>());
+
+ private static KeyMaterial RSA_KEY;
+ private static KeyMaterial AES_KEY;
+
+ @BeforeAll
+ static void setupKeys() throws Exception {
+ KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
+ keyPairGen.initialize(2048);
+ KeyPair keyPair = keyPairGen.generateKeyPair();
+
+ RSA_KEY = KeyMaterial.builder()
+ .rsaKey(ByteBuffer.wrap(keyPair.getPrivate().getEncoded()))
+ .build();
+
+ KeyGenerator keyGen = KeyGenerator.getInstance("AES");
+ keyGen.init(256);
+ SecretKey aesSecretKey = keyGen.generateKey();
+
+ AES_KEY = KeyMaterial.builder()
+ .aesKey(ByteBuffer.wrap(aesSecretKey.getEncoded()))
+ .build();
+ }
+
+ /**
+ * Public accessors for ranged get tests to retrieve encrypted object keys
+ */
+ static List getCbcObjects() {
+ return new ArrayList<>(cbcObjects);
+ }
+
+ static List getGcmObjects() {
+ return new ArrayList<>(gcmObjects);
+ }
+
+ static List getKcGcmObjectsMetadata() {
+ return new ArrayList<>(kcGcmObjectsMetadata);
+ }
+
+ static List getKcGcmObjectsInstruction() {
+ return new ArrayList<>(kcGcmObjectsInstruction);
+ }
+
+ static List getMutatedCObjectsMetadata() {
+ return new ArrayList<>(mutatedCObjectsMetadata);
+ }
+
+ static List getMutatedDObjectsMetadata() {
+ return new ArrayList<>(mutatedDObjectsMetadata);
+ }
+
+ static List getMutatedIObjectsMetadata() {
+ return new ArrayList<>(mutatedIObjectsMetadata);
+ }
+
+ static List getInvalidDLengthShortMetadata() {
+ return new ArrayList<>(invalidDLengthShortMetadata);
+ }
+
+ static List getInvalidDLengthLongMetadata() {
+ return new ArrayList<>(invalidDLengthLongMetadata);
+ }
+
+ static List getMutatedCObjectsInstruction() {
+ return new ArrayList<>(mutatedCObjectsInstruction);
+ }
+
+ static List getMutatedDObjectsInstruction() {
+ return new ArrayList<>(mutatedDObjectsInstruction);
+ }
+
+ static List getMutatedIObjectsInstruction() {
+ return new ArrayList<>(mutatedIObjectsInstruction);
+ }
+
+ static List getInvalidDLengthShortInstruction() {
+ return new ArrayList<>(invalidDLengthShortInstruction);
+ }
+
+ static List getInvalidDLengthLongInstruction() {
+ return new ArrayList<>(invalidDLengthLongInstruction);
+ }
+
+ static KeyMaterial getKmsKeyArn() {
+ return kmsKeyArn;
+ }
+
+ static KeyMaterial getRsaKey() {
+ return RSA_KEY;
+ }
+
+ static KeyMaterial getAesKey() {
+ return AES_KEY;
+ }
+
+ // GCM can be encrypted by transition and improved clients
+ public static Stream transitionAndImprovedForGCM() {
+ return Stream.concat(
+ transitionClientsForTest(),
+ improvedClientsForTest()
+ );
+ }
+
+ // KC-GCM can be encrypted by improved clients only
+ public static Stream improvedClientsForKCGCM() {
+ return improvedClientsForTest();
+ }
+
+ public static Stream improvedClientsCanPutKMSWithInstructionFile() {
+ return improvedClientsForTest()
+ .filter(target -> !INSTRUCTION_FILE_PUT_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()))
+ .filter(target -> !KMS_INSTRUCTION_FILE_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+ }
+
+ @org.junit.jupiter.api.Test
+ void encryptCbcForRangedGets() {
+ // Use old V1 client for CBC encryption (legacy algorithm)
+ // Only Java V1 client is available - no V1 test servers for other languages
+ EncryptionMaterialsProvider materialsProvider = new KMSEncryptionMaterialsProvider(TestUtils.KMS_KEY_ARN);
+
+ CryptoConfiguration v1Config =
+ new CryptoConfiguration(CryptoMode.EncryptionOnly)
+ .withStorageMode(CryptoStorageMode.ObjectMetadata)
+ .withAwsKmsRegion(TestUtils.KMS_REGION);
+
+ AmazonS3Encryption v1Client = AmazonS3EncryptionClient.encryptionBuilder()
+ .withCryptoConfiguration(v1Config)
+ .withEncryptionMaterials(materialsProvider)
+ .build();
+
+ String objectKey = appendTestSuffix(sharedObjectKeyBase + "-cbc-java");
+ v1Client.putObject(TestUtils.BUCKET, objectKey, objectKey);
+ cbcObjects.add(objectKey);
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt GCM for ranged get testing")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$EncryptTests#transitionAndImprovedForGCM")
+ void encryptGcmForRangedGets(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + "-gcm-" + language.getLanguageName()),
+ gcmObjects,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt KC-GCM with Object Metadata Storage for ranged get testing")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$EncryptTests#improvedClientsForKCGCM")
+ void encryptKcGcmMetadataForRangedGets(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + "-kc-gcm-metadata-" + language.getLanguageName()),
+ kcGcmObjectsMetadata,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+
+ @ParameterizedTest(name = "{0}: Encrypt KC-GCM with Instruction file Storage for ranged get testing")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$EncryptTests#improvedClientsCanPutKMSWithInstructionFile")
+ void encryptKcGcmInstructionFileForRangedGets(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .instructionFileConfig(
+ InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build()
+ )
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(
+ client,
+ S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + "-kc-gcm-instruction-java" + language.getLanguageName()),
+ kcGcmObjectsInstruction,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ /**
+ * Flips a random bit in the given byte array
+ * @param data The byte array to modify
+ * @return The bit position that was flipped
+ */
+ static int flipRandomBit(byte[] data) {
+ if (data.length == 0) {
+ return -1;
+ }
+ int bitPosition = random.nextInt(data.length * 8);
+ int byteIndex = bitPosition / 8;
+ int bitIndex = bitPosition % 8;
+ data[byteIndex] ^= (1 << bitIndex);
+ return bitPosition;
+ }
+
+ /**
+ * Creates corrupted copies of KC-GCM objects for failure testing
+ * Handles both object metadata storage and instruction file storage modes
+ */
+ static void createCorruptedCopies() throws Exception {
+ try (S3Client ptS3Client = S3Client.create()) {
+ ObjectMapper mapper = new ObjectMapper();
+
+ // Process metadata storage mode objects (all V3 keys in metadata, no instruction file)
+ for (String objectKey : kcGcmObjectsMetadata) {
+ ResponseBytes encryptedObject = ptS3Client.getObjectAsBytes(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+
+ byte[] objectData = encryptedObject.asByteArray();
+ Map objectMetadata = encryptedObject.response().metadata();
+
+ // Create good copy
+ putObjectWithMetadata(ptS3Client, objectKey + SUFFIX_GOOD_COPY, objectData, objectMetadata);
+
+ // Extract commitment values from metadata
+ String commitC = objectMetadata.get("x-amz-c");
+ String commitD = objectMetadata.get("x-amz-d");
+ String commitI = objectMetadata.get("x-amz-i");
+
+ // Create mutated commitment copies in metadata
+ if (commitC != null) {
+ byte[] commitCBytes = Base64.getDecoder().decode(commitC);
+ int bitPos = flipRandomBit(commitCBytes);
+ String mutatedC = Base64.getEncoder().encodeToString(commitCBytes);
+ Map mutatedMetadata = new java.util.HashMap<>(objectMetadata);
+ mutatedMetadata.put("x-amz-c", mutatedC);
+ String mutatedKey = objectKey + SUFFIX_BAD_MUTATED_C + bitPos;
+ putObjectWithMetadata(ptS3Client, mutatedKey, objectData, mutatedMetadata);
+ mutatedCObjectsMetadata.add(mutatedKey);
+ }
+
+ if (commitD != null) {
+ byte[] commitDBytes = Base64.getDecoder().decode(commitD);
+ int bitPos = flipRandomBit(commitDBytes);
+ String mutatedD = Base64.getEncoder().encodeToString(commitDBytes);
+ Map mutatedMetadata = new java.util.HashMap<>(objectMetadata);
+ mutatedMetadata.put("x-amz-d", mutatedD);
+ String mutatedKey = objectKey + SUFFIX_BAD_MUTATED_D + bitPos;
+ putObjectWithMetadata(ptS3Client, mutatedKey, objectData, mutatedMetadata);
+ mutatedDObjectsMetadata.add(mutatedKey);
+ }
+
+ if (commitI != null) {
+ byte[] commitIBytes = Base64.getDecoder().decode(commitI);
+ int bitPos = flipRandomBit(commitIBytes);
+ String mutatedI = Base64.getEncoder().encodeToString(commitIBytes);
+ Map mutatedMetadata = new java.util.HashMap<>(objectMetadata);
+ mutatedMetadata.put("x-amz-i", mutatedI);
+ String mutatedKey = objectKey + SUFFIX_BAD_MUTATED_I + bitPos;
+ putObjectWithMetadata(ptS3Client, mutatedKey, objectData, mutatedMetadata);
+ mutatedIObjectsMetadata.add(mutatedKey);
+ }
+
+ // Create invalid D length copies (metadata storage)
+ if (commitD != null) {
+ byte[] commitDBytes = Base64.getDecoder().decode(commitD);
+
+ // Short D (< 28 bytes) - truncate to 20 bytes
+ int shortLength = Math.min(20, commitDBytes.length);
+ byte[] shortDBytes = new byte[shortLength];
+ System.arraycopy(commitDBytes, 0, shortDBytes, 0, shortLength);
+ String shortD = Base64.getEncoder().encodeToString(shortDBytes);
+ Map shortDMetadata = new java.util.HashMap<>(objectMetadata);
+ shortDMetadata.put("x-amz-d", shortD);
+ String shortDKey = objectKey + SUFFIX_BAD_INVALID_D_LENGTH_SHORT;
+ putObjectWithMetadata(ptS3Client, shortDKey, objectData, shortDMetadata);
+ invalidDLengthShortMetadata.add(shortDKey);
+
+ // Long D (> 28 bytes) - extend to 40 bytes
+ byte[] longDBytes = new byte[40];
+ System.arraycopy(commitDBytes, 0, longDBytes, 0, commitDBytes.length);
+ // Fill remaining bytes with zeros
+ for (int i = commitDBytes.length; i < 40; i++) {
+ longDBytes[i] = 0;
+ }
+ String longD = Base64.getEncoder().encodeToString(longDBytes);
+ Map longDMetadata = new java.util.HashMap<>(objectMetadata);
+ longDMetadata.put("x-amz-d", longD);
+ String longDKey = objectKey + SUFFIX_BAD_INVALID_D_LENGTH_LONG;
+ putObjectWithMetadata(ptS3Client, longDKey, objectData, longDMetadata);
+ invalidDLengthLongMetadata.add(longDKey);
+ }
+ }
+
+ // Process instruction file storage mode objects (c/d/i in metadata, x-amz-3/w/m/t in instruction file)
+ for (String objectKey : kcGcmObjectsInstruction) {
+ // Get the encrypted object
+ ResponseBytes encryptedObject = ptS3Client.getObjectAsBytes(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+
+ byte[] objectData = encryptedObject.asByteArray();
+ Map objectMetadata = encryptedObject.response().metadata();
+
+ // Get the instruction file
+ ResponseBytes instructionObject = ptS3Client.getObjectAsBytes(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey + ".instruction")
+ .build());
+
+ String originalInstructionFileJson = new String(instructionObject.asByteArray(), StandardCharsets.UTF_8);
+
+ // Create good copy (both object and instruction file)
+ putObjectWithInstructionFile(
+ ptS3Client,
+ objectKey + SUFFIX_GOOD_COPY,
+ objectData,
+ objectMetadata,
+ originalInstructionFileJson
+ );
+
+ // Extract commitment values from metadata
+ String commitC = objectMetadata.get("x-amz-c");
+ String commitD = objectMetadata.get("x-amz-d");
+ String commitI = objectMetadata.get("x-amz-i");
+
+ // Corruption: Add c/d/i to instruction file (duplication - should fail)
+ Map corruptedInstructionMap = mapper.readValue(originalInstructionFileJson, Map.class);
+ corruptedInstructionMap.put("x-amz-c", commitC);
+ corruptedInstructionMap.put("x-amz-d", commitD);
+ corruptedInstructionMap.put("x-amz-i", commitI);
+ String corruptedInstructionJson = mapper.writeValueAsString(corruptedInstructionMap);
+
+ putObjectWithInstructionFile(
+ ptS3Client,
+ objectKey + SUFFIX_BAD_COMMITMENT_IN_INSTRUCTION,
+ objectData,
+ objectMetadata,
+ corruptedInstructionJson
+ );
+
+ // Create mutated commitment copies in metadata
+ if (commitC != null) {
+ byte[] commitCBytes = Base64.getDecoder().decode(commitC);
+ int bitPos = flipRandomBit(commitCBytes);
+ String mutatedC = Base64.getEncoder().encodeToString(commitCBytes);
+ Map mutatedMetadata = new java.util.HashMap<>(objectMetadata);
+ mutatedMetadata.put("x-amz-c", mutatedC);
+ String mutatedKey = objectKey + SUFFIX_BAD_MUTATED_C + bitPos;
+ putObjectWithInstructionFile(ptS3Client, mutatedKey, objectData, mutatedMetadata, originalInstructionFileJson);
+ mutatedCObjectsInstruction.add(mutatedKey);
+ }
+
+ if (commitD != null) {
+ byte[] commitDBytes = Base64.getDecoder().decode(commitD);
+ int bitPos = flipRandomBit(commitDBytes);
+ String mutatedD = Base64.getEncoder().encodeToString(commitDBytes);
+ Map mutatedMetadata = new java.util.HashMap<>(objectMetadata);
+ mutatedMetadata.put("x-amz-d", mutatedD);
+ String mutatedKey = objectKey + SUFFIX_BAD_MUTATED_D + bitPos;
+ putObjectWithInstructionFile(ptS3Client, mutatedKey, objectData, mutatedMetadata, originalInstructionFileJson);
+ mutatedDObjectsInstruction.add(mutatedKey);
+ }
+
+ if (commitI != null) {
+ byte[] commitIBytes = Base64.getDecoder().decode(commitI);
+ int bitPos = flipRandomBit(commitIBytes);
+ String mutatedI = Base64.getEncoder().encodeToString(commitIBytes);
+ Map mutatedMetadata = new java.util.HashMap<>(objectMetadata);
+ mutatedMetadata.put("x-amz-i", mutatedI);
+ String mutatedKey = objectKey + SUFFIX_BAD_MUTATED_I + bitPos;
+ putObjectWithInstructionFile(ptS3Client, mutatedKey, objectData, mutatedMetadata, originalInstructionFileJson);
+ mutatedIObjectsInstruction.add(mutatedKey);
+ }
+
+ // Create invalid D length copies (instruction file storage)
+ if (commitD != null) {
+ byte[] commitDBytes = Base64.getDecoder().decode(commitD);
+
+ // Short D (< 28 bytes) - truncate to 20 bytes
+ int shortLength = Math.min(20, commitDBytes.length);
+ byte[] shortDBytes = new byte[shortLength];
+ System.arraycopy(commitDBytes, 0, shortDBytes, 0, shortLength);
+ String shortD = Base64.getEncoder().encodeToString(shortDBytes);
+ Map shortDMetadata = new java.util.HashMap<>(objectMetadata);
+ shortDMetadata.put("x-amz-d", shortD);
+ String shortDKey = objectKey + SUFFIX_BAD_INVALID_D_LENGTH_SHORT;
+ putObjectWithInstructionFile(ptS3Client, shortDKey, objectData, shortDMetadata, originalInstructionFileJson);
+ invalidDLengthShortInstruction.add(shortDKey);
+
+ // Long D (> 28 bytes) - extend to 40 bytes
+ byte[] longDBytes = new byte[40];
+ System.arraycopy(commitDBytes, 0, longDBytes, 0, commitDBytes.length);
+ // Fill remaining bytes with zeros
+ for (int i = commitDBytes.length; i < 40; i++) {
+ longDBytes[i] = 0;
+ }
+ String longD = Base64.getEncoder().encodeToString(longDBytes);
+ Map longDMetadata = new java.util.HashMap<>(objectMetadata);
+ longDMetadata.put("x-amz-d", longD);
+ String longDKey = objectKey + SUFFIX_BAD_INVALID_D_LENGTH_LONG;
+ putObjectWithInstructionFile(ptS3Client, longDKey, objectData, longDMetadata, originalInstructionFileJson);
+ invalidDLengthLongInstruction.add(longDKey);
+ }
+ }
+ }
+ }
+
+ static void putObjectWithMetadata(
+ S3Client ptS3Client,
+ String objectKey,
+ byte[] objectData,
+ Map objectMetadata
+ ) {
+ ptS3Client.putObject(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .metadata(objectMetadata)
+ .build(),
+ software.amazon.awssdk.core.sync.RequestBody.fromBytes(objectData));
+ }
+
+ static void putObjectWithInstructionFile(
+ S3Client ptS3Client,
+ String objectKey,
+ byte[] objectData,
+ Map objectMetadata,
+ String instructionFileJson
+ ) {
+ // Put the encrypted object
+ ptS3Client.putObject(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .metadata(objectMetadata)
+ .build(),
+ software.amazon.awssdk.core.sync.RequestBody.fromBytes(objectData));
+
+ // Put the instruction file
+ ptS3Client.putObject(builder -> builder
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey + ".instruction")
+ .build(),
+ software.amazon.awssdk.core.sync.RequestBody.fromBytes(
+ instructionFileJson.getBytes(StandardCharsets.UTF_8)));
+ }
+
+ @AfterAll
+ static void signalEncryptionComplete() throws Exception {
+ createCorruptedCopies();
+
+ // Signal that all encryption tests have completed
+ encryptPhaseComplete.countDown();
+ }
+ }
+
+ /**
+ * Ranged Get Tests - Test Phase
+ *
+ * These tests perform ranged get operations on objects encrypted by EncryptTests.
+ * All tests in this class can run fully in parallel with each other.
+ * They depend on EncryptTests completing first.
+ */
+ @Nested
+ @DisplayName("RangedGetTests - RangedGet")
+ class RangedGetTestsNested {
+ private static List cbcObjects;
+ private static List gcmObjects;
+ private static List kcGcmObjects;
+ private static List kcGcmObjectsInstruction;
+ private static List mutatedCObjects;
+ private static List mutatedDObjects;
+ private static List mutatedIObjects;
+ private static List mutatedCObjectsInstruction;
+ private static List mutatedDObjectsInstruction;
+ private static List mutatedIObjectsInstruction;
+ private static KeyMaterial kmsKeyArn;
+ private static KeyMaterial RSA_KEY;
+ private static KeyMaterial AES_KEY;
+
+ @BeforeAll
+ static void setup() throws InterruptedException {
+ // Wait for all encryption tests to complete
+ encryptPhaseComplete.await();
+
+ // Import encrypted objects from the encrypt phase
+ cbcObjects = EncryptTests.getCbcObjects();
+ gcmObjects = EncryptTests.getGcmObjects();
+ // Import KC-GCM objects for both storage modes
+ kcGcmObjects = EncryptTests.getKcGcmObjectsMetadata();
+ kcGcmObjectsInstruction = EncryptTests.getKcGcmObjectsInstruction();
+ // Import corrupted objects for metadata storage mode
+ mutatedCObjects = EncryptTests.getMutatedCObjectsMetadata();
+ mutatedDObjects = EncryptTests.getMutatedDObjectsMetadata();
+ mutatedIObjects = EncryptTests.getMutatedIObjectsMetadata();
+ // Import corrupted objects for instruction file storage mode
+ mutatedCObjectsInstruction = EncryptTests.getMutatedCObjectsInstruction();
+ mutatedDObjectsInstruction = EncryptTests.getMutatedDObjectsInstruction();
+ mutatedIObjectsInstruction = EncryptTests.getMutatedIObjectsInstruction();
+ kmsKeyArn = EncryptTests.getKmsKeyArn();
+ RSA_KEY = EncryptTests.getRsaKey();
+ AES_KEY = EncryptTests.getAesKey();
+
+ // Verify we have objects to test
+ if (cbcObjects.isEmpty() && gcmObjects.isEmpty() && kcGcmObjects.isEmpty()) {
+ throw new IllegalStateException(
+ "No encrypted objects found. Ensure EncryptTests runs first.");
+ }
+ }
+
+ public static Stream rangedGetSupportedClients() {
+ Stream improved = improvedClientsForTest()
+ .filter(target -> RANGED_GETS_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+
+ Stream transition = transitionClientsForTest()
+ .filter(target -> RANGED_GETS_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+
+ return Stream.concat(improved, transition);
+ }
+
+ public static Stream rangedGetCBCSupportedClients() {
+ return rangedGetSupportedClients()
+ // This is just a quick hack. Perhaps it would be good to have an equivalent group for languages.
+ .filter(target -> !((LanguageServerTarget) target.get()[0]).getLanguageName().startsWith("CPP"));
+ }
+
+ // CBC Ranged Get Tests
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get CBC objects - start range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetCBCSupportedClients")
+ void rangedGetCbcStartSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .enableLegacyWrappingAlgorithms(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ cbcObjects,
+ 0,
+ 5,
+ EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get CBC objects - end range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetCBCSupportedClients")
+ void rangedGetCbcEndSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .enableLegacyWrappingAlgorithms(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // For each object, get its length and test the last 5 bytes
+ for (String objectKey : cbcObjects) {
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+ long rangeStart = Math.max(0, objectLength - 5);
+ long rangeEnd = objectLength - 1;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(objectKey),
+ rangeStart,
+ rangeEnd,
+ EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF
+ );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get CBC objects - middle range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetCBCSupportedClients")
+ void rangedGetCbcMiddleSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .enableLegacyWrappingAlgorithms(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ cbcObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get CBC objects - whole file")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetCBCSupportedClients")
+ void rangedGetCbcWholeFileSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .enableLegacyWrappingAlgorithms(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // For each object, get its length and test the whole file using range
+ for (String objectKey : cbcObjects) {
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(objectKey),
+ 0,
+ objectLength - 1,
+ EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF
+ );
+ }
+ }
+
+ // // GCM Ranged Get Tests
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get GCM objects - start range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetGcmStartSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ gcmObjects,
+ 0,
+ 5,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get GCM objects - end range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetGcmEndSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // For each object, get its length and test the last 5 bytes
+ for (String objectKey : gcmObjects) {
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+ long rangeStart = Math.max(0, objectLength - 5);
+ long rangeEnd = objectLength - 1;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(objectKey),
+ rangeStart,
+ rangeEnd,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF
+ );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get GCM objects - middle range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetGcmMiddleSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ gcmObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get GCM objects - whole file")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetGcmWholeFileSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // For each object, get its length and test the whole file using range
+ for (String objectKey : gcmObjects) {
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(objectKey),
+ 0,
+ objectLength - 1,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF
+ );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get GCM objects - Include tag")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetGcmTagOnlySucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
+ .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ gcmObjects,
+ 10,
+ 1000,
+ EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF
+ );
+ }
+
+ // KC-GCM Ranged Get Tests - Valid Objects
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM objects - start range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmStartSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjects,
+ 0,
+ 5,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjects
+ .stream()
+ .map(key -> key + "-good-copy")
+ .collect(Collectors.toList()),
+ 0,
+ 5,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM objects - middle range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmMiddleSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjects
+ .stream()
+ .map(key -> key + "-good-copy")
+ .collect(Collectors.toList()),
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM objects - Include tag")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmTagOnlySucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjects,
+ 10,
+ 1000,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjects
+ .stream()
+ .map(key -> key + "-good-copy")
+ .collect(Collectors.toList()),
+ 10,
+ 1000,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM objects - end range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmEndSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // Test original objects
+ for (String objectKey : kcGcmObjects) {
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+ long rangeStart = Math.max(0, objectLength - 5);
+ long rangeEnd = objectLength - 1;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(objectKey),
+ rangeStart,
+ rangeEnd,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ // Test good-copy objects
+ for (String objectKey : kcGcmObjects) {
+ String goodCopyKey = objectKey + "-good-copy";
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(goodCopyKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+ long rangeStart = Math.max(0, objectLength - 5);
+ long rangeEnd = objectLength - 1;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(goodCopyKey),
+ rangeStart,
+ rangeEnd,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM objects - whole file")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmWholeFileSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // Test original objects
+ for (String objectKey : kcGcmObjects) {
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(objectKey),
+ 0,
+ objectLength - 1,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ // Test good-copy objects
+ for (String objectKey : kcGcmObjects) {
+ String goodCopyKey = objectKey + "-good-copy";
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(goodCopyKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(goodCopyKey),
+ 0,
+ objectLength - 1,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+ }
+
+ // KC-GCM Instruction File Storage - Valid Object Tests
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM Instruction File objects - start range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionStartSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjectsInstruction,
+ 0,
+ 5,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjectsInstruction
+ .stream()
+ .map(key -> key + "-good-copy")
+ .collect(Collectors.toList()),
+ 0,
+ 5,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM Instruction File objects - middle range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionMiddleSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjectsInstruction,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjectsInstruction
+ .stream()
+ .map(key -> key + "-good-copy")
+ .collect(Collectors.toList()),
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM Instruction File objects - Include tag")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionTagOnlySucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjectsInstruction,
+ 10,
+ 1000,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ kcGcmObjectsInstruction
+ .stream()
+ .map(key -> key + "-good-copy")
+ .collect(Collectors.toList()),
+ 10,
+ 1000,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM Instruction File objects - end range")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionEndSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // Test original objects
+ for (String objectKey : kcGcmObjectsInstruction) {
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+ long rangeStart = Math.max(0, objectLength - 5);
+ long rangeEnd = objectLength - 1;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(objectKey),
+ rangeStart,
+ rangeEnd,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ // Test good-copy objects
+ for (String objectKey : kcGcmObjectsInstruction) {
+ String goodCopyKey = objectKey + "-good-copy";
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(goodCopyKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+ long rangeStart = Math.max(0, objectLength - 5);
+ long rangeEnd = objectLength - 1;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(goodCopyKey),
+ rangeStart,
+ rangeEnd,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: Successfully ranged get KC-GCM Instruction File objects - whole file")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionWholeFileSucceeds(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // Test original objects
+ for (String objectKey : kcGcmObjectsInstruction) {
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(objectKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(objectKey),
+ 0,
+ objectLength - 1,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ // Test good-copy objects
+ for (String objectKey : kcGcmObjectsInstruction) {
+ String goodCopyKey = objectKey + "-good-copy";
+ GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder()
+ .clientID(S3ECId)
+ .bucket(TestUtils.BUCKET)
+ .key(goodCopyKey)
+ .build());
+ long objectLength = fullOutput.getBody().array().length;
+
+ TestUtils.RangedGet(
+ client,
+ S3ECId,
+ java.util.Collections.singletonList(goodCopyKey),
+ 0,
+ objectLength - 1,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+ }
+
+ // KC-GCM Ranged Get Tests - Failure Cases
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM Instruction File with commitment duplicated in instruction file")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionCommitmentInInstructionFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ // Test instruction file storage mode objects with c/d/i duplicated into instruction file
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ kcGcmObjectsInstruction.stream()
+ .map(key -> key + "-bad-commitment-in-instruction")
+ .collect(Collectors.toList()),
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM with mutated commitment C")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmMutatedCFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ mutatedCObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM with mutated commitment D")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmMutatedDFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ mutatedDObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM with mutated commitment I")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmMutatedIFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ mutatedIObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM Instruction File with mutated commitment C in metadata")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionMutatedCFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ mutatedCObjectsInstruction,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM Instruction File with mutated commitment D in metadata")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionMutatedDFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ mutatedDObjectsInstruction,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM Instruction File with mutated commitment I in metadata")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionMutatedIFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ mutatedIObjectsInstruction,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM with invalid C length (too short) in metadata")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmMetadataInvalidCLengthShortFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ List invalidDLengthShortObjects = EncryptTests.getInvalidDLengthShortMetadata();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ invalidDLengthShortObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM with invalid D length (too long) in metadata")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmMetadataInvalidDLengthLongFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ List invalidDLengthLongObjects = EncryptTests.getInvalidDLengthLongMetadata();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ invalidDLengthLongObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM Instruction File with invalid D length (too short) in metadata")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionInvalidDLengthShortFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ List invalidDLengthShortObjects = EncryptTests.getInvalidDLengthShortInstruction();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ invalidDLengthShortObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+
+ @ParameterizedTest(name = "{0}: Fail to ranged get KC-GCM Instruction File with invalid D length (too long) in metadata")
+ @MethodSource("software.amazon.encryption.s3.RangedGetTests$RangedGetTestsNested#rangedGetSupportedClients")
+ void rangedGetKcGcmInstructionInvalidDLengthLongFails(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(kmsKeyArn)
+ .enableLegacyUnauthenticatedModes(true)
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ List invalidDLengthLongObjects = EncryptTests.getInvalidDLengthLongInstruction();
+
+ TestUtils.RangedGet_fails(
+ client,
+ S3ECId,
+ invalidDLengthLongObjects,
+ 5,
+ 10,
+ EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY
+ );
+ }
+ }
+}
diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/ReEncryptTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/ReEncryptTests.java
new file mode 100644
index 00000000..3053afb6
--- /dev/null
+++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/ReEncryptTests.java
@@ -0,0 +1,648 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.encryption.s3;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+import static software.amazon.encryption.s3.TestUtils.*;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.stream.Stream;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import com.amazonaws.services.s3.AmazonS3Encryption;
+import com.amazonaws.services.s3.AmazonS3EncryptionClient;
+import com.amazonaws.services.s3.model.CryptoConfiguration;
+import com.amazonaws.services.s3.model.CryptoMode;
+import com.amazonaws.services.s3.model.CryptoStorageMode;
+import com.amazonaws.services.s3.model.EncryptionMaterialsProvider;
+import com.amazonaws.services.s3.model.KMSEncryptionMaterialsProvider;
+
+import software.amazon.encryption.s3.TestUtils.LanguageServerTarget;
+import software.amazon.encryption.s3.client.S3ECTestServerClient;
+import software.amazon.encryption.s3.model.CommitmentPolicy;
+import software.amazon.encryption.s3.model.CreateClientInput;
+import software.amazon.encryption.s3.model.CreateClientOutput;
+import software.amazon.encryption.s3.model.EncryptionAlgorithm;
+import software.amazon.encryption.s3.model.InstructionFileConfig;
+import software.amazon.encryption.s3.model.KeyMaterial;
+import software.amazon.encryption.s3.model.ReEncryptInput;
+import software.amazon.encryption.s3.model.ReEncryptOutput;
+import software.amazon.encryption.s3.model.S3ECConfig;
+import software.amazon.encryption.s3.model.S3EncryptionClientError;
+
+/**
+ * ReEncrypt Instruction File Tests - S3 Encryption Client Cross-Language Compatibility
+ *
+ * PURPOSE:
+ * This test suite validates that instruction file re-encryption enables key rotation without
+ * re-uploading encrypted objects, and that re-encrypted objects maintain cross-language
+ * compatibility and commitment validation guarantees.
+ *
+ * WHAT IS BEING TESTED:
+ * 1. Instruction file re-encryption for KC-GCM algorithm with raw keyrings
+ * 2. Re-encryption across different raw keyring types (AES, RSA)
+ * 3. Same-type keyring rotation (AES => AES, RSA => RSA)
+ * 4. Cross-type keyring rotation (AES => RSA, RSA => AES)
+ * 5. Default instruction file suffix (.instruction) and custom suffixes (.instruction-rsa, .instruction-aes)
+ * 6. Cross-language compatibility: all languages can decrypt after re-encryption
+ * 7. Rotation enforcement to prevent re-encryption with the same key
+ *
+ * WHY THIS IS IMPORTANT:
+ * - Key rotation is a critical security operation that should not require expensive object re-uploads
+ * - ReEncryptInstructionFile enables updating the encrypted data key without touching the ciphertext
+ * - Raw keyrings (AES, RSA) provide direct key material access required for re-encryption
+ * - Cross-type rotation (e.g., AES to RSA) enables flexibility in key management strategies
+ * - Commitment validation must be maintained even when instruction files are re-encrypted
+ * - Cross-language compatibility ensures key rotation doesn't break existing clients
+ * - Rotation enforcement prevents accidental re-encryption with the same key material
+ * - Custom instruction file suffixes enable sharing encrypted objects with partners
+ *
+ * TEST STRUCTURE:
+ * This suite uses a three-phase approach with enforced ordering:
+ * 1. EncryptTests - Encrypts objects with instruction files using AES and RSA keyrings
+ * - All encrypt tests can run in parallel within this phase
+ * - Signals encryptPhaseComplete latch when done
+ * 2. ReEncryptTests - Waits for encryption to complete, then re-encrypts instruction files
+ * - Tests same-type rotations (AES => AES, RSA => RSA)
+ * - Tests cross-type rotations (AES => RSA with .instruction-rsa suffix, RSA => AES with .instruction-aes suffix)
+ * - Tests rotation enforcement (same key rejection)
+ * - All re-encrypt tests can run in parallel within this phase
+ * - Tracks which objects were re-encrypted to which keys to prevent conflicts
+ * - Signals reEncryptPhaseComplete latch when done
+ * 3. DecryptReEncryptedTests - Waits for re-encryption to complete, then tests decryption
+ * - Tests cross-language decryption compatibility after re-encryption
+ * - Uses tracked object lists to decrypt with correct keys and custom instruction file suffixes
+ * - All decrypt tests can run in parallel within this phase
+ *
+ * Coordination uses two CountDownLatches:
+ * - encryptPhaseComplete: Ensures all encryption completes before re-encryption begins
+ * - reEncryptPhaseComplete: Ensures all re-encryption completes before decryption begins
+ *
+ * INPUT DIMENSIONS:
+ * - Source Key Material: AES (256-bit), RSA (2048-bit key pairs)
+ * - Destination Key Material: Different AES or RSA keys (raw keyrings)
+ * - Encryption Algorithm: KC-GCM (ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY)
+ * - Instruction File Suffix: default (.instruction), custom (.instruction-rsa, .instruction-aes)
+ * - Language for Re-encryption: Java V3-Transition, Java V4 (RE_ENCRYPT_SUPPORTED)
+ * - Language for Decryption: All languages supporting instruction files
+ * - Rotation Enforcement: enforceRotation flag (true/false)
+ *
+ * EXPECTED RESULTS:
+ * - Positive: Re-encryption succeeds with different key material, all languages can decrypt
+ * - Negative: Re-encryption fails when enforceRotation detects same key material
+ *
+ * REPRESENTATIVE VALUES:
+ * - Object keys themselves (short strings) serve as representative small plaintext files
+ * - Instruction file suffix: ".instruction" (default), ".instruction-rsa", ".instruction-aes"
+ * - Key materials: Generated once per type and reused across tests
+ *
+ * FILTERING:
+ * - Only languages in RE_ENCRYPT_SUPPORTED can perform re-encryption operations
+ * - Languages in INSTRUCTION_FILE_GET_UNSUPPORTED cannot decrypt with instruction files
+ *
+ * NOTE: KMS keyrings are NOT supported for re-encryption as the reEncryptInstructionFile
+ * method requires RawKeyring instances (AES or RSA) which provide direct access to key material.
+ *
+ */
+public class ReEncryptTests {
+ // Synchronization latches for three-phase coordination
+ private static final CountDownLatch encryptPhaseComplete = new CountDownLatch(1);
+ private static final CountDownLatch reEncryptPhaseComplete = new CountDownLatch(1);
+
+ // Tracking lists for re-encrypted objects - shared across nested test classes
+ private static final List reEncryptedAesToAes = Collections.synchronizedList(new ArrayList<>());
+ private static final List reEncryptedRsaToRsa = Collections.synchronizedList(new ArrayList<>());
+ private static final List reEncryptedAesToRsa = Collections.synchronizedList(new ArrayList<>());
+ private static final List reEncryptedRsaToAesDefault = Collections.synchronizedList(new ArrayList<>());
+ private static final List reEncryptedAesToRsaDefault = Collections.synchronizedList(new ArrayList<>());
+
+ @Nested
+ @DisplayName("ReEncryptTests - Encrypt")
+ class EncryptTests {
+ private static final String sharedObjectKeyBase = "test-reencrypt";
+
+ private static SecretKey aesKey1, aesKey2;
+ private static KeyMaterial aesKeyMaterial1, aesKeyMaterial2;
+ private static KeyPair rsaKeyPair1, rsaKeyPair2;
+ private static KeyMaterial rsaKeyMaterial1, rsaKeyMaterial2;
+
+ // Separate object lists for each re-encryption path to avoid conflicts
+ private static final List kcGcmObjectsAesToAes = Collections.synchronizedList(new ArrayList<>());
+ private static final List kcGcmObjectsAesToRsaCustom = Collections.synchronizedList(new ArrayList<>());
+ private static final List kcGcmObjectsAesToRsaDefault = Collections.synchronizedList(new ArrayList<>());
+ private static final List kcGcmObjectsRsaToRsa = Collections.synchronizedList(new ArrayList<>());
+ private static final List kcGcmObjectsRsaToAesDefault = Collections.synchronizedList(new ArrayList<>());
+
+ @BeforeAll
+ static void generateKeys() throws Exception {
+ KeyGenerator aesKeyGen = KeyGenerator.getInstance("AES");
+ aesKeyGen.init(256);
+ aesKey1 = aesKeyGen.generateKey();
+ aesKey2 = aesKeyGen.generateKey();
+
+ Map aesMatDesc1 = new HashMap<>();
+ aesMatDesc1.put("keyId", "aes-key-1");
+ aesKeyMaterial1 = KeyMaterial.builder()
+ .aesKey(ByteBuffer.wrap(aesKey1.getEncoded()))
+ .materialsDescription(aesMatDesc1)
+ .build();
+
+ Map aesMatDesc2 = new HashMap<>();
+ aesMatDesc2.put("keyId", "aes-key-2");
+ aesKeyMaterial2 = KeyMaterial.builder()
+ .aesKey(ByteBuffer.wrap(aesKey2.getEncoded()))
+ .materialsDescription(aesMatDesc2)
+ .build();
+
+ KeyPairGenerator rsaKeyGen = KeyPairGenerator.getInstance("RSA");
+ rsaKeyGen.initialize(2048);
+ rsaKeyPair1 = rsaKeyGen.generateKeyPair();
+ rsaKeyPair2 = rsaKeyGen.generateKeyPair();
+
+ Map rsaMatDesc1 = new HashMap<>();
+ rsaMatDesc1.put("keyId", "rsa-key-1");
+ rsaKeyMaterial1 = KeyMaterial.builder()
+ .rsaKey(ByteBuffer.wrap(rsaKeyPair1.getPrivate().getEncoded()))
+ .materialsDescription(rsaMatDesc1)
+ .build();
+
+ Map rsaMatDesc2 = new HashMap<>();
+ rsaMatDesc2.put("keyId", "rsa-key-2");
+ rsaKeyMaterial2 = KeyMaterial.builder()
+ .rsaKey(ByteBuffer.wrap(rsaKeyPair2.getPrivate().getEncoded()))
+ .materialsDescription(rsaMatDesc2)
+ .build();
+ }
+
+ static List getKcGcmObjectsAesToAes() { return new ArrayList<>(kcGcmObjectsAesToAes); }
+ static List getKcGcmObjectsAesToRsaCustom() { return new ArrayList<>(kcGcmObjectsAesToRsaCustom); }
+ static List getKcGcmObjectsAesToRsaDefault() { return new ArrayList<>(kcGcmObjectsAesToRsaDefault); }
+ static List getKcGcmObjectsRsaToRsa() { return new ArrayList<>(kcGcmObjectsRsaToRsa); }
+ static List getKcGcmObjectsRsaToAesDefault() { return new ArrayList<>(kcGcmObjectsRsaToAesDefault); }
+ static KeyMaterial getAesKeyMaterial1() { return aesKeyMaterial1; }
+ static KeyMaterial getAesKeyMaterial2() { return aesKeyMaterial2; }
+ static KeyMaterial getRsaKeyMaterial1() { return rsaKeyMaterial1; }
+ static KeyMaterial getRsaKeyMaterial2() { return rsaKeyMaterial2; }
+
+ public static Stream improvedClientsCanPutRawRSAWithInstructionFile() {
+ return improvedClientsForTest()
+ .filter(target -> !INSTRUCTION_FILE_PUT_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()))
+ .filter(target -> RAW_RSA_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+ }
+
+ public static Stream improvedClientsCanPutRawAESWithInstructionFile() {
+ return improvedClientsForTest()
+ .filter(target -> !INSTRUCTION_FILE_PUT_UNSUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()))
+ .filter(target -> RAW_AES_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt AES objects for AES => AES re-encryption")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$EncryptTests#improvedClientsCanPutRawAESWithInstructionFile")
+ void encryptAesForAesToAesReencrypt(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(aesKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + "-aes-to-aes-" + language.getLanguageName()),
+ kcGcmObjectsAesToAes, EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt AES objects for AES => RSA custom suffix re-encryption")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$EncryptTests#improvedClientsCanPutRawAESWithInstructionFile")
+ void encryptAesForAesToRsaCustomReencrypt(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(aesKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + "-aes-to-rsa-custom-" + language.getLanguageName()),
+ kcGcmObjectsAesToRsaCustom, EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt AES objects for AES => RSA default suffix re-encryption")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$EncryptTests#improvedClientsCanPutRawAESWithInstructionFile")
+ void encryptAesForAesToRsaDefaultReencrypt(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(aesKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + "-aes-to-rsa-default-" + language.getLanguageName()),
+ kcGcmObjectsAesToRsaDefault, EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt RSA objects for RSA => RSA re-encryption")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$EncryptTests#improvedClientsCanPutRawRSAWithInstructionFile")
+ void encryptRsaForRsaToRsaReencrypt(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(rsaKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + "-rsa-to-rsa-" + language.getLanguageName()),
+ kcGcmObjectsRsaToRsa, EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @ParameterizedTest(name = "{0}: Encrypt RSA objects for RSA => AES default suffix re-encryption")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$EncryptTests#improvedClientsCanPutRawRSAWithInstructionFile")
+ void encryptRsaForRsaToAesDefaultReencrypt(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(rsaKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ TestUtils.Encrypt(client, S3ECId,
+ appendTestSuffix(sharedObjectKeyBase + "-rsa-to-aes-default-" + language.getLanguageName()),
+ kcGcmObjectsRsaToAesDefault, EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY);
+ }
+
+ @AfterAll
+ static void signalEncryptionComplete() {
+ encryptPhaseComplete.countDown();
+ }
+ }
+
+ @Nested
+ @DisplayName("ReEncryptTests - ReEncrypt")
+ class ReEncryptTestsNested {
+ private static List kcGcmObjectsAesToAes, kcGcmObjectsAesToRsaCustom, kcGcmObjectsAesToRsaDefault;
+ private static List kcGcmObjectsRsaToRsa, kcGcmObjectsRsaToAesDefault;
+ private static KeyMaterial aesKeyMaterial1, aesKeyMaterial2, rsaKeyMaterial1, rsaKeyMaterial2;
+
+ @BeforeAll
+ static void setup() throws InterruptedException {
+ encryptPhaseComplete.await();
+ kcGcmObjectsAesToAes = EncryptTests.getKcGcmObjectsAesToAes();
+ kcGcmObjectsAesToRsaCustom = EncryptTests.getKcGcmObjectsAesToRsaCustom();
+ kcGcmObjectsAesToRsaDefault = EncryptTests.getKcGcmObjectsAesToRsaDefault();
+ kcGcmObjectsRsaToRsa = EncryptTests.getKcGcmObjectsRsaToRsa();
+ kcGcmObjectsRsaToAesDefault = EncryptTests.getKcGcmObjectsRsaToAesDefault();
+ aesKeyMaterial1 = EncryptTests.getAesKeyMaterial1();
+ aesKeyMaterial2 = EncryptTests.getAesKeyMaterial2();
+ rsaKeyMaterial1 = EncryptTests.getRsaKeyMaterial1();
+ rsaKeyMaterial2 = EncryptTests.getRsaKeyMaterial2();
+ }
+
+ public static Stream reencryptSupportedClients() {
+ return improvedClientsForTest()
+ .filter(target -> RE_ENCRYPT_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName()));
+ }
+
+ @ParameterizedTest(name = "{0}: ReEncrypt AES => AES instruction file")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$ReEncryptTestsNested#reencryptSupportedClients")
+ void reencryptAesToAesInstructionFile(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+
+ for (int i = 0; i < kcGcmObjectsAesToAes.size(); i++) {
+ String objectKey = kcGcmObjectsAesToAes.get(i);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(aesKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ ReEncryptOutput response = client.reEncrypt(ReEncryptInput.builder()
+ .bucket(TestUtils.BUCKET).key(objectKey).clientID(S3ECId)
+ .newKeyMaterial(aesKeyMaterial2).build());
+
+ assertNotNull(response);
+ reEncryptedAesToAes.add(objectKey);
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: ReEncrypt RSA => RSA instruction file")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$ReEncryptTestsNested#reencryptSupportedClients")
+ void reencryptRsaToRsaInstructionFile(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+
+ for (int i = 0; i < kcGcmObjectsRsaToRsa.size(); i++) {
+ String objectKey = kcGcmObjectsRsaToRsa.get(i);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(rsaKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ ReEncryptOutput response = client.reEncrypt(ReEncryptInput.builder()
+ .bucket(TestUtils.BUCKET).key(objectKey).clientID(S3ECId)
+ .newKeyMaterial(rsaKeyMaterial2).build());
+
+ assertNotNull(response);
+ reEncryptedRsaToRsa.add(objectKey);
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: ReEncrypt AES => RSA instruction file with custom suffix")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$ReEncryptTestsNested#reencryptSupportedClients")
+ void reencryptAesToRsaInstructionFile(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+
+ for (int i = 0; i < kcGcmObjectsAesToRsaCustom.size(); i++) {
+ String objectKey = kcGcmObjectsAesToRsaCustom.get(i);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(aesKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ ReEncryptOutput response = client.reEncrypt(ReEncryptInput.builder()
+ .bucket(TestUtils.BUCKET).key(objectKey).clientID(S3ECId)
+ .newKeyMaterial(rsaKeyMaterial1)
+ // Java always prepends a `.`
+ .instructionFileSuffix("instruction-rsa")
+ .build());
+
+ assertNotNull(response);
+ reEncryptedAesToRsa.add(objectKey);
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: ReEncrypt RSA => AES instruction file (default suffix)")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$ReEncryptTestsNested#reencryptSupportedClients")
+ void reencryptRsaToAesDefaultInstructionFile(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+
+ for (int i = 0; i < kcGcmObjectsRsaToAesDefault.size(); i++) {
+ String objectKey = kcGcmObjectsRsaToAesDefault.get(i);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(rsaKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ ReEncryptOutput response = client.reEncrypt(ReEncryptInput.builder()
+ .bucket(TestUtils.BUCKET).key(objectKey).clientID(S3ECId)
+ .newKeyMaterial(aesKeyMaterial1)
+ .build());
+
+ assertNotNull(response);
+ reEncryptedRsaToAesDefault.add(objectKey);
+ }
+ }
+
+ @ParameterizedTest(name = "{0}: ReEncrypt AES => RSA instruction file (default suffix)")
+ @MethodSource("software.amazon.encryption.s3.ReEncryptTests$ReEncryptTestsNested#reencryptSupportedClients")
+ void reencryptAesToRsaDefaultInstructionFile(TestUtils.LanguageServerTarget language) {
+ S3ECTestServerClient client = TestUtils.testServerClientFor(language);
+
+ for (int i = 0; i < kcGcmObjectsAesToRsaDefault.size(); i++) {
+ String objectKey = kcGcmObjectsAesToRsaDefault.get(i);
+ CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder()
+ .config(S3ECConfig.builder()
+ .keyMaterial(aesKeyMaterial1)
+ .instructionFileConfig(InstructionFileConfig.builder()
+ .enableInstructionFilePutObject(true)
+ .build())
+ .build())
+ .build());
+ String S3ECId = clientOutput.getClientId();
+
+ ReEncryptOutput response = client.reEncrypt(ReEncryptInput.builder()
+ .bucket(TestUtils.BUCKET).key(objectKey).clientID(S3ECId)
+ .newKeyMaterial(rsaKeyMaterial1)
+ .build());
+
+ assertNotNull(response);
+ reEncryptedAesToRsaDefault.add(objectKey);
+ }
+ }
+
+ @AfterAll
+ static void signalReEncryptionComplete() {
+ reEncryptPhaseComplete.countDown();
+ }
+ }
+
+ @Nested
+ @DisplayName("ReEncryptTests - DecryptReEncrypted")
+ class DecryptReEncryptedTests {
+ private static KeyMaterial aesKeyMaterial1, aesKeyMaterial2, rsaKeyMaterial1, rsaKeyMaterial2;
+
+ @BeforeAll
+ static void setup() throws InterruptedException {
+ reEncryptPhaseComplete.await();
+ aesKeyMaterial1 = EncryptTests.getAesKeyMaterial1();
+ aesKeyMaterial2 = EncryptTests.getAesKeyMaterial2();
+ rsaKeyMaterial1 = EncryptTests.getRsaKeyMaterial1();
+ rsaKeyMaterial2 = EncryptTests.getRsaKeyMaterial2();
+ }
+
+ public static Stream