diff --git a/.github/workflows/all-ci.yml b/.github/workflows/all-ci.yml new file mode 100644 index 00000000..c65364b7 --- /dev/null +++ b/.github/workflows/all-ci.yml @@ -0,0 +1,47 @@ +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 + + 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/duvet-test-server.yml b/.github/workflows/duvet-test-server.yml new file mode 100644 index 00000000..f4bac5a8 --- /dev/null +++ b/.github/workflows/duvet-test-server.yml @@ -0,0 +1,104 @@ +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@v5 + with: + submodules: true + token: ${{ secrets.PAT_FOR_SPEC }} + + - name: Checkout CPP code cpp-v3 + uses: actions/checkout@v5 + 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: Clone duvet repository + run: git clone https://github.com/awslabs/duvet.git /tmp/duvet + + - name: Build and install duvet + run: | + cd /tmp/duvet + cargo xtask build + cargo install --path ./duvet + + - name: Run duvet + if: always() + run: cd test-server && make duvet + + - name: Upload duvet reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: 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@v4 + 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/lint.yml b/.github/workflows/lint.yml index 16057711..bb1655bb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,7 +8,7 @@ on: jobs: lint: - runs-on: ubuntu-latest + runs-on: macos-15 steps: - name: Checkout code diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 675a0099..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Main Workflow - -on: - push: - branches: [ staging ] - pull_request: - workflow_dispatch: - inputs: - python-version: - description: 'Python version to use' - default: '3.11' - required: false - type: string - -jobs: - lint: - name: Lint - uses: ./.github/workflows/lint.yml - - run-tests: - name: Run Tests - uses: ./.github/workflows/test.yml - with: - python-version: ${{ inputs.python-version || '3.11' }} - permissions: - id-token: write - contents: read - secrets: inherit diff --git a/.github/workflows/python-integ.yml b/.github/workflows/python-integ.yml new file mode 100644 index 00000000..9e5ae818 --- /dev/null +++ b/.github/workflows/python-integ.yml @@ -0,0 +1,57 @@ +name: Python Integration Tests + +on: + workflow_call: + inputs: + python-version: + description: "Python version to use" + default: "3.11" + required: false + type: string + +jobs: + python-integ: + runs-on: macos-14-large + permissions: + id-token: write + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + submodules: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version || '3.11' }} + + - name: Cache uv dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/uv + key: ${{ runner.os }}-uv-${{ hashFiles('./test-server/python-v3-server/**/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@v4 + with: + role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role + aws-region: us-west-2 + + - name: Run unit tests + run: make test-unit + + - name: Run integration tests + run: make test-integration + env: + CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }} + CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }} diff --git a/.github/workflows/test-server.yml b/.github/workflows/test-server.yml new file mode 100644 index 00000000..4fa10666 --- /dev/null +++ b/.github/workflows/test-server.yml @@ -0,0 +1,150 @@ +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@v5 + 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@v5 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 }} + + - 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@v4 + 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@v4 + with: + 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@v4 + 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@v4 + with: + name: results + path: test-server/java-tests/build/reports/tests/integ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index f8025246..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: Run 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: - runs-on: ubuntu-latest - permissions: - id-token: write - contents: read - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python-version || '3.11' }} - - # Cache uv dependencies - - name: Cache uv dependencies - uses: actions/cache@v3 - with: - path: ~/.cache/uv - key: ${{ runner.os }}-uv-${{ hashFiles('**/pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-uv- - - - name: Install Uv - run: pip install uv - - # Cache Gradle dependencies and build outputs - - name: Cache Gradle packages - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - test-server/java-server/.gradle - test-server/java-tests/.gradle - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Install dependencies - run: make install - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - role-to-assume: arn:aws:iam::370957321024:role/S3EC-Python-Github-test-role - aws-region: us-west-2 - - - name: Run unit tests - run: make test-unit - - - name: Run integration tests - run: make test-integration - env: - CI_S3_BUCKET: ${{ vars.CI_S3_BUCKET }} - CI_KMS_KEY_ALIAS: ${{ vars.CI_KMS_KEY_ALIAS }} - - - name: Run test-server tests - run: cd test-server && make ci - 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" diff --git a/.gitignore b/.gitignore index 0e29a9fb..5cd8f239 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ __pycache__/ # Distribution / packaging dist/ build/ +bin/ *.egg-info/ # Uv @@ -51,3 +52,6 @@ gradle-app.setting .DS_Store smithy-java-core/out + +# test server +*.pid diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..75e91f99 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,48 @@ +[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 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/lib/cdk-stack.ts b/cdk/lib/cdk-stack.ts index cdb7c489..1fad4b74 100644 --- a/cdk/lib/cdk-stack.ts +++ b/cdk/lib/cdk-stack.ts @@ -10,6 +10,8 @@ import { PolicyDocument, PolicyStatement, FederatedPrincipal, + ArnPrincipal, + CompositePrincipal, ManagedPolicy, } from "aws-cdk-lib/aws-iam"; import { @@ -99,23 +101,31 @@ export class S3ECPythonGithub extends cdk.Stack { 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 + "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 + "arn:aws:s3:::aws-net-sdk-*", // permission for S3EC .net bucket. For S3EC-NET repo ], }), ] @@ -155,16 +165,29 @@ export class S3ECPythonGithub extends cdk.Stack { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" }, "StringLike": { - "token.actions.githubusercontent.com:sub": "repo:aws/amazon-s3-encryption-client-python:*" + "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: GithubActionsPrincipal, + 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: "/", diff --git a/test-server/Makefile b/test-server/Makefile index afbe97b5..21b5c98b 100644 --- a/test-server/Makefile +++ b/test-server/Makefile @@ -1,63 +1,81 @@ # Makefile for S3 Encryption Client Testing -.PHONY: all start-servers start-python-server start-java-server run-tests stop-servers clean ci check-env help - -# Default target -all: start-servers run-tests +.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 -ci: start-servers run-tests stop-servers +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) -# Start Python server in background -start-python-server: - @echo "Starting Python server..." - cd python-server && \ - python -m venv .venv && \ - .venv/bin/python -m ensurepip && \ - .venv/bin/python -m pip install -e . && \ - .venv/bin/python -m pip install -e ../.. && \ - 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" \ - .venv/bin/python src/main.py & echo $$! > ../python-server.pid - @echo "Python server starting..." +BUILD_SERVER_TARGETS := $(addprefix build-, $(SERVER_DIRS)) +START_SERVER_TARGETS := $(addprefix start-, $(SERVER_DIRS)) +WAIT_SERVER_TARGETS := $(addprefix wait-, $(SERVER_DIRS)) -# Start Java server in background -start-java-server: - @echo "Starting Java server..." - cd java-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" \ - ./gradlew --build-cache --parallel run & echo $$! > ../java-server.pid - @echo "Java server starting..." - -# Start both servers in parallel -start-servers: - @echo "Starting servers in parallel..." - @$(MAKE) -j2 start-python-server start-java-server - @echo "Waiting for servers to be ready..." - @for i in $$(seq 1 360); do \ - if nc -z localhost 8080 && nc -z localhost 8081; then \ - echo "Ports are open, waiting for servers to initialize..."; \ - sleep 5; \ - echo "Both servers are ready!"; \ - break; \ - fi; \ - if [ $$i -eq 360 ]; then \ - echo "Timeout waiting for servers to start"; \ - exit 1; \ - fi; \ - echo "Waiting for servers to start ($$i/360)..."; \ - sleep 1; \ +# 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 -run-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 @@ -66,46 +84,75 @@ run-tests: AWS_SECRET_ACCESS_KEY="$$AWS_SECRET_ACCESS_KEY" \ AWS_SESSION_TOKEN="$$AWS_SESSION_TOKEN" \ AWS_REGION="us-west-2" \ - ./gradlew --build-cache --parallel integ + ./gradlew --build-cache --info --parallel --no-daemon integ \ + $(if $(TEST),--tests "$(TEST)",) \ + -Dtest.filter.servers="$(FILTER)" @echo "Tests completed successfully" # Stop the servers -stop-servers: +test-servers-stop: @echo "Stopping servers..." - @if [ -f python-server.pid ]; then \ - kill $$(cat python-server.pid) 2>/dev/null || true; \ - rm python-server.pid; \ - fi - @if [ -f java-server.pid ]; then \ - kill $$(cat java-server.pid) 2>/dev/null || true; \ - rm java-server.pid; \ - fi + @for dir in $(SERVER_DIRS); do \ + echo "Stopping server in $$dir..."; \ + $(MAKE) -C $$dir stop-server; \ + done @echo "Servers stopped" -# Clean up logs and pid files -clean: stop-servers - @echo "Cleaning up..." - @rm -f python-server.log java-server.log - @echo "Cleanup complete" - # Help target -help: +test-servers-help: @echo "Available targets:" - @echo " all : Start servers and run tests (default, output to stdout)" - @echo " ci : Run in CI mode (start servers, run tests, stop servers)" - @echo " start-servers : Start Python and Java servers in parallel (output to stdout)" - @echo " start-python-server: Start only the Python server" - @echo " start-java-server : Start only the Java server" - @echo " run-tests : Run Java tests" - @echo " stop-servers : Stop running servers" - @echo " clean : Stop servers and clean up logs" - @echo " check-env : Check if required environment variables are set" - @echo " help : Show this help message" + @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 -check-env: +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 index a320d1d1..48187fc3 100644 --- a/test-server/README.md +++ b/test-server/README.md @@ -28,11 +28,8 @@ make ci # Start Python and Java servers in parallel make start-servers -# Start only the Python server -make start-python-server - -# Start only the Java server -make start-java-server +# Start only the Python S3EC V3 server +make start-python-v3-server # Run Java tests make run-tests @@ -59,3 +56,28 @@ Performance optimizations have been implemented to speed up the test-server CI p - 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/java-server/gradle.properties b/test-server/java-server/gradle.properties deleted file mode 100644 index 08afce82..00000000 --- a/test-server/java-server/gradle.properties +++ /dev/null @@ -1,11 +0,0 @@ -# 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-server/src/main/java/software/amazon/encryption/s3/CreateClientOperationImpl.java b/test-server/java-server/src/main/java/software/amazon/encryption/s3/CreateClientOperationImpl.java deleted file mode 100644 index d992c435..00000000 --- a/test-server/java-server/src/main/java/software/amazon/encryption/s3/CreateClientOperationImpl.java +++ /dev/null @@ -1,109 +0,0 @@ -package software.amazon.encryption.s3; - -import software.amazon.awssdk.core.traits.Trait; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.encryption.s3.S3EncryptionClient; -import software.amazon.encryption.s3.materials.AesKeyring; -import software.amazon.encryption.s3.materials.Keyring; -import software.amazon.encryption.s3.materials.KmsKeyring; -import software.amazon.encryption.s3.materials.PartialRsaKeyPair; -import software.amazon.encryption.s3.materials.RsaKeyring; -import software.amazon.smithy.java.core.schema.Schema; -import software.amazon.smithy.java.server.RequestContext; -import software.amazon.encryption.s3.model.CreateClientInput; -import software.amazon.encryption.s3.model.CreateClientOutput; -import software.amazon.encryption.s3.model.GenericServerError; -import software.amazon.encryption.s3.model.KeyMaterial; -import software.amazon.encryption.s3.service.CreateClientOperation; - -import javax.crypto.spec.SecretKeySpec; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.util.Arrays; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -public class CreateClientOperationImpl implements CreateClientOperation { - private Map clientCache_; - - public CreateClientOperationImpl(Map clientCache) { - clientCache_ = clientCache; - } - - // Copied from S3EC. - private boolean onlyOneNonNull(Object... values) { - boolean haveOneNonNull = false; - for (Object o : values) { - if (o != null) { - if (haveOneNonNull) { - return false; - } - - haveOneNonNull = true; - } - } - - return haveOneNonNull; - } - - @Override - public CreateClientOutput createClient(CreateClientInput input, RequestContext context) { - try { - KeyMaterial key = input.getConfig().getKeyMaterial(); - if (!onlyOneNonNull(key.getAesKey(), key.getKmsKeyId(), key.getRsaKey())) { - throw new RuntimeException("KeyMaterial must be only one, non-null input!"); - } - Keyring keyring; - if (key.getAesKey() != null) { - byte[] keyBytes = new byte[key.getAesKey().remaining()]; - key.getAesKey().get(keyBytes); - keyring = AesKeyring.builder() - .wrappingKey(new SecretKeySpec(keyBytes, "AES")) - .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) - .build(); - } else if (key.getRsaKey() != null) { - try { - byte[] keyBytes = new byte[key.getRsaKey().remaining()]; - key.getRsaKey().get(keyBytes); - PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); - KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - keyring = RsaKeyring.builder() - .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) - .wrappingKeyPair(PartialRsaKeyPair.builder() - .privateKey(keyFactory.generatePrivate(keySpec)).build()) - .build(); - } catch (NoSuchAlgorithmException | InvalidKeySpecException nse) { - throw new RuntimeException(nse); - } - } else if (key.getKmsKeyId() != null) { - keyring = KmsKeyring.builder() - .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) - .wrappingKeyId(key.getKmsKeyId()) - .build(); - } else { - throw new RuntimeException("No KeyMaterial found!"); - } - S3Client s3Client = S3EncryptionClient.builder() - .keyring(keyring) - .build(); - UUID uuid = UUID.randomUUID(); - String uuidString = uuid.toString(); - clientCache_.put(uuidString, s3Client); - return CreateClientOutput.builder() - .clientId(uuidString) - .build(); - } catch (Exception e) { - StringWriter sw = new StringWriter(); - e.printStackTrace(new PrintWriter(sw)); - String stackTrace = sw.toString(); - throw GenericServerError.builder() - .message(stackTrace) - .build(); - } - } -} diff --git a/test-server/java-server/src/main/java/software/amazon/encryption/s3/GetObjectOperationImpl.java b/test-server/java-server/src/main/java/software/amazon/encryption/s3/GetObjectOperationImpl.java deleted file mode 100644 index e7c5493f..00000000 --- a/test-server/java-server/src/main/java/software/amazon/encryption/s3/GetObjectOperationImpl.java +++ /dev/null @@ -1,72 +0,0 @@ -package software.amazon.encryption.s3; - -import software.amazon.awssdk.core.ResponseBytes; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.GetObjectResponse; -import software.amazon.encryption.s3.S3EncryptionClientException; -import software.amazon.smithy.java.server.RequestContext; -import software.amazon.encryption.s3.model.GenericServerError; -import software.amazon.encryption.s3.model.GetObjectInput; -import software.amazon.encryption.s3.model.GetObjectOutput; -import software.amazon.encryption.s3.model.S3EncryptionClientError; -import software.amazon.encryption.s3.service.GetObjectOperation; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.nio.ByteBuffer; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import static software.amazon.encryption.s3.S3EncryptionClient.withAdditionalConfiguration; -import static software.amazon.encryption.s3.MetadataUtils.metadataListToMap; -import static software.amazon.encryption.s3.MetadataUtils.metadataMapToList; - -public class GetObjectOperationImpl implements GetObjectOperation { - private Map clientCache_; - public GetObjectOperationImpl(Map clientCache) { - clientCache_ = clientCache; - } - @Override - public GetObjectOutput getObject(GetObjectInput input, RequestContext context) { - try { - S3Client s3Client = clientCache_.get(input.getClientID()); - Map ecMap = metadataListToMap(input.getMetadata()); - - try { - ResponseBytes resp = s3Client.getObjectAsBytes(builder -> builder - .bucket(input.getBucket()) - .key(input.getKey()) - .overrideConfiguration(withAdditionalConfiguration(ecMap))); - - List mdAsList = metadataMapToList(resp.response().metadata()); - // Can't use asBB else it gets mad bc cant access backing array - ByteBuffer bb = ByteBuffer.wrap(resp.asByteArray()); - GetObjectOutput output = GetObjectOutput.builder() - .body(bb) - .metadata(mdAsList) - .build(); - return output; - } catch (S3EncryptionClientException s3EncryptionClientException) { - // Modeled exceptions MUST be returned as such - StringWriter sw = new StringWriter(); - s3EncryptionClientException.printStackTrace(new PrintWriter(sw)); - String stackTrace = sw.toString(); - throw S3EncryptionClientError.builder() - .message(stackTrace) - .build(); - } - } catch (Exception e) { - // Don't wrap modeled errors - if (e instanceof S3EncryptionClientError) { - throw e; - } - StringWriter sw = new StringWriter(); - e.printStackTrace(new PrintWriter(sw)); - String stackTrace = sw.toString(); - throw GenericServerError.builder() - .message(stackTrace) - .build(); - } - } -} diff --git a/test-server/java-server/src/main/java/software/amazon/encryption/s3/MetadataUtils.java b/test-server/java-server/src/main/java/software/amazon/encryption/s3/MetadataUtils.java deleted file mode 100644 index 036289ec..00000000 --- a/test-server/java-server/src/main/java/software/amazon/encryption/s3/MetadataUtils.java +++ /dev/null @@ -1,43 +0,0 @@ -package software.amazon.encryption.s3; - -import software.amazon.encryption.s3.model.GenericServerError; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -public class MetadataUtils { - - /** - * Annoyingly, Smithy doesn't provide an interface for map types - * in HTTP headers, so we have to do the serde ourselves - */ - public static List metadataMapToList(Map md) { - List mdAsList = new ArrayList<>(md.size()); - for (Map.Entry keyValue : md.entrySet()) { - mdAsList.add("[" + keyValue.getKey() + "]:[" + keyValue.getValue() + "]"); - } - return mdAsList; - } - - public static Map metadataListToMap(List mdList) { - Map md = new HashMap<>(); - for (String entry : mdList) { - // Split on "]:[" to separate key and value - String[] parts = entry.split("]:\\["); - if (parts.length == 2) { - // Remove remaining brackets from start and end - String key = parts[0].substring(1); - String value = parts[1].substring(0, parts[1].length() - 1); - md.put(key, value); - } else { - throw GenericServerError.builder() - .message("Malformed metadata list entry: " + entry) - .build(); - } - } - return md; - } - -} diff --git a/test-server/java-server/src/main/java/software/amazon/encryption/s3/PutObjectOperationImpl.java b/test-server/java-server/src/main/java/software/amazon/encryption/s3/PutObjectOperationImpl.java deleted file mode 100644 index 4c772673..00000000 --- a/test-server/java-server/src/main/java/software/amazon/encryption/s3/PutObjectOperationImpl.java +++ /dev/null @@ -1,55 +0,0 @@ -package software.amazon.encryption.s3; - -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectResponse; -import software.amazon.smithy.java.server.RequestContext; -import software.amazon.encryption.s3.model.GenericServerError; -import software.amazon.encryption.s3.model.PutObjectInput; -import software.amazon.encryption.s3.model.PutObjectOutput; -import software.amazon.encryption.s3.service.PutObjectOperation; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.util.ArrayList; -import java.util.Map; -import java.util.stream.Collectors; - -import static software.amazon.encryption.s3.S3EncryptionClient.withAdditionalConfiguration; -import static software.amazon.encryption.s3.MetadataUtils.metadataListToMap; - -public class PutObjectOperationImpl implements PutObjectOperation { - - private Map clientCache_; - - public PutObjectOperationImpl(Map clientCache) { - clientCache_ = clientCache; - } - - @Override - public PutObjectOutput putObject(PutObjectInput input, RequestContext context) { - try { - final Map metadata = metadataListToMap(input.getMetadata()); - S3Client s3Client = clientCache_.get(input.getClientID()); - s3Client.putObject(builder -> builder - .bucket(input.getBucket()) - .key(input.getKey()) - .overrideConfiguration(withAdditionalConfiguration(metadata)), - RequestBody.fromByteBuffer(input.getBody()) - ); - // The real S3 doesn't provide bucket/key/metadata, so Test doesn't need to either, but we do anyway - return PutObjectOutput.builder() - .bucket(input.getBucket()) - .key(input.getKey()) - .metadata(input.getMetadata()) - .build(); - } catch (Exception e) { - StringWriter sw = new StringWriter(); - e.printStackTrace(new PrintWriter(sw)); - String stackTrace = sw.toString(); - throw GenericServerError.builder() - .message(stackTrace) - .build(); - } - } -} diff --git a/test-server/java-tests/README.md b/test-server/java-tests/README.md index eee84863..2a9b80ee 100644 --- a/test-server/java-tests/README.md +++ b/test-server/java-tests/README.md @@ -1,8 +1,8 @@ -## Java Tests +# Java Tests This project contains Java client tests for the S3 Encryption Client. -### Running Tests +## Running Tests To run the integration tests for this project: diff --git a/test-server/java-tests/build.gradle.kts b/test-server/java-tests/build.gradle.kts index f35a2ac6..2d1cbdeb 100644 --- a/test-server/java-tests/build.gradle.kts +++ b/test-server/java-tests/build.gradle.kts @@ -16,7 +16,11 @@ dependencies { // 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") } @@ -46,6 +50,30 @@ tasks { 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") } } 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/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 clientsCanGetRawRSAWithInstructionFile() { + return Stream.concat( + improvedClientsForTest().filter(target -> RAW_RSA_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())), + transitionClientsForTest().filter(target -> RAW_RSA_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())) + ); + } + + public static Stream clientsCanGetRawAESWithInstructionFile() { + return Stream.concat( + improvedClientsForTest().filter(target -> RAW_AES_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())), + transitionClientsForTest().filter(target -> RAW_AES_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())) + ); + } + + public static Stream clientsCanGetRawRSAWithInstructionFileAndCustomSuffix() { + return Stream.concat( + improvedClientsForTest() + .filter(target -> RAW_RSA_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())) + .filter(target -> CUSTOM_INSTRUCTION_SUFFIX_GET_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())), + transitionClientsForTest() + .filter(target -> RAW_RSA_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())) + .filter(target -> CUSTOM_INSTRUCTION_SUFFIX_GET_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())) + ); + } + + public static Stream clientsCanGetRawAESWithInstructionFileAndCustomSuffix() { + return Stream.concat( + improvedClientsForTest() + .filter(target -> RAW_AES_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())) + .filter(target -> CUSTOM_INSTRUCTION_SUFFIX_GET_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())), + transitionClientsForTest() + .filter(target -> RAW_AES_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())) + .filter(target -> CUSTOM_INSTRUCTION_SUFFIX_GET_SUPPORTED.contains(((LanguageServerTarget) target.get()[0]).getLanguageName())) + ); + } + + @ParameterizedTest(name = "{0}: Decrypt AES => AES re-encrypted objects") + @MethodSource("software.amazon.encryption.s3.ReEncryptTests$DecryptReEncryptedTests#clientsCanGetRawAESWithInstructionFile") + void decryptReencryptedAesToAesObjects(TestUtils.LanguageServerTarget language) { + if (reEncryptedAesToAes.isEmpty()) return; + S3ECTestServerClient client = TestUtils.testServerClientFor(language); + CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder().keyMaterial(aesKeyMaterial2).build()) + .build()); + + // C++ clients require materials description to be passed per-operation + if (language.getLanguageName().startsWith("CPP")) { + TestUtils.DecryptWithMaterialsDescription(client, clientOutput.getClientId(), + reEncryptedAesToAes, aesKeyMaterial2, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } else { + TestUtils.Decrypt(client, clientOutput.getClientId(), reEncryptedAesToAes, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } + } + + @ParameterizedTest(name = "{0}: Decrypt RSA => RSA re-encrypted objects") + @MethodSource("software.amazon.encryption.s3.ReEncryptTests$DecryptReEncryptedTests#clientsCanGetRawRSAWithInstructionFile") + void decryptReencryptedRsaToRsaObjects(TestUtils.LanguageServerTarget language) { + if (reEncryptedRsaToRsa.isEmpty()) return; + S3ECTestServerClient client = TestUtils.testServerClientFor(language); + CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder().keyMaterial(rsaKeyMaterial2).build()) + .build()); + + // C++ clients require materials description to be passed per-operation + if (language.getLanguageName().startsWith("CPP")) { + TestUtils.DecryptWithMaterialsDescription(client, clientOutput.getClientId(), + reEncryptedRsaToRsa, rsaKeyMaterial2, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } else { + TestUtils.Decrypt(client, clientOutput.getClientId(), reEncryptedRsaToRsa, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } + } + + @ParameterizedTest(name = "{0}: Decrypt AES => RSA re-encrypted objects with custom suffix") + @MethodSource("software.amazon.encryption.s3.ReEncryptTests$DecryptReEncryptedTests#clientsCanGetRawRSAWithInstructionFileAndCustomSuffix") + void decryptReencryptedAesToRsaObjects(TestUtils.LanguageServerTarget language) { + if (reEncryptedAesToRsa.isEmpty()) return; + S3ECTestServerClient client = TestUtils.testServerClientFor(language); + CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(rsaKeyMaterial1) + .build()) + .build()); + + // C++ clients require materials description to be passed per-operation + if (language.getLanguageName().startsWith("CPP")) { + TestUtils.DecryptWithMaterialsDescription(client, clientOutput.getClientId(), + reEncryptedAesToRsa, rsaKeyMaterial1, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } else { + TestUtils.Decrypt(client, clientOutput.getClientId(), reEncryptedAesToRsa, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY, + reEncryptedAesToRsa, ".instruction-rsa"); + } + } + + @ParameterizedTest(name = "{0}: Decrypt RSA => AES re-encrypted objects (default suffix)") + @MethodSource("software.amazon.encryption.s3.ReEncryptTests$DecryptReEncryptedTests#clientsCanGetRawAESWithInstructionFile") + void decryptReencryptedRsaToAesDefaultObjects(TestUtils.LanguageServerTarget language) { + if (reEncryptedRsaToAesDefault.isEmpty()) return; + S3ECTestServerClient client = TestUtils.testServerClientFor(language); + CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(aesKeyMaterial1) + .build()) + .build()); + + // C++ clients require materials description to be passed per-operation + if (language.getLanguageName().startsWith("CPP")) { + TestUtils.DecryptWithMaterialsDescription(client, clientOutput.getClientId(), + reEncryptedRsaToAesDefault, aesKeyMaterial1, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } else { + TestUtils.Decrypt(client, clientOutput.getClientId(), reEncryptedRsaToAesDefault, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } + } + + @ParameterizedTest(name = "{0}: Decrypt AES => RSA re-encrypted objects (default suffix)") + @MethodSource("software.amazon.encryption.s3.ReEncryptTests$DecryptReEncryptedTests#clientsCanGetRawRSAWithInstructionFile") + void decryptReencryptedAesToRsaDefaultObjects(TestUtils.LanguageServerTarget language) { + if (reEncryptedAesToRsaDefault.isEmpty()) return; + S3ECTestServerClient client = TestUtils.testServerClientFor(language); + CreateClientOutput clientOutput = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(rsaKeyMaterial1) + .build()) + .build()); + + // C++ clients require materials description to be passed per-operation + if (language.getLanguageName().startsWith("CPP")) { + TestUtils.DecryptWithMaterialsDescription(client, clientOutput.getClientId(), + reEncryptedAesToRsaDefault, rsaKeyMaterial1, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } else { + TestUtils.Decrypt(client, clientOutput.getClientId(), reEncryptedAesToRsaDefault, + EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } + } + } +} diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RoundTripTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RoundTripTests.java index 211269d7..e6cfae84 100644 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RoundTripTests.java +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RoundTripTests.java @@ -6,44 +6,44 @@ package software.amazon.encryption.s3; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; 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.net.Socket; -import java.net.URI; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.security.KeyPair; +import java.security.KeyPairGenerator; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.stream.Stream; +import com.amazonaws.services.s3.AmazonS3EncryptionClientV2; +import com.amazonaws.services.s3.AmazonS3EncryptionV2; +import com.amazonaws.services.s3.model.CryptoConfigurationV2; 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.smithy.java.aws.client.restjson.RestJsonClientProtocol; -import software.amazon.smithy.java.client.core.ClientConfig; -import software.amazon.smithy.java.client.core.ClientProtocol; -import software.amazon.smithy.java.client.core.endpoint.EndpointResolver; +import org.opentest4j.TestAbortedException; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +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.GetObjectInput; import software.amazon.encryption.s3.model.GetObjectOutput; +import software.amazon.encryption.s3.model.InstructionFileConfig; 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.S3ECTestServerApiService; import software.amazon.encryption.s3.model.S3EncryptionClientError; -import software.amazon.smithy.java.http.api.HttpRequest; -import software.amazon.smithy.java.http.api.HttpResponse; -import com.amazonaws.regions.Region; -import com.amazonaws.regions.Regions; import com.amazonaws.services.s3.AmazonS3Encryption; import com.amazonaws.services.s3.AmazonS3EncryptionClient; import com.amazonaws.services.s3.model.CryptoConfiguration; @@ -53,137 +53,29 @@ import com.amazonaws.services.s3.model.KMSEncryptionMaterialsProvider; public class RoundTripTests { - private static final List serverList; - private static final Map serverMap; - - private static final String KMS_KEY_ARN = System.getenv("TEST_SERVER_KMS_KEY_ARN") != null ? - System.getenv("TEST_SERVER_KMS_KEY_ARN") : "arn:aws:kms:us-west-2:370957321024:alias/S3EC-Test-Server-Github-KMS-Key"; - private static final Region KMS_REGION = Region.getRegion(Regions.fromName("us-west-2")); - private static final String BUCKET = System.getenv("TEST_SERVER_S3_BUCKET") != null ? - System.getenv("TEST_SERVER_S3_BUCKET") : "s3ec-test-server-github-bucket"; - - static { - serverList = new ArrayList<>(2); - serverList.add(new LanguageServerTarget("Java", "8080")); - serverList.add(new LanguageServerTarget("Python", "8081")); - - serverMap = new HashMap<>(2); - serverMap.put("Java", new LanguageServerTarget("Java", "8080")); - serverMap.put("Python", new LanguageServerTarget("Python", "8081")); - } - - static public class LanguageServerTarget { - public String getLanguageName() { - return languageName; - } - - public URI getServerURI() { - return serverURI; - } - - private final String baseURI = "http://localhost"; - private String languageName; - private URI serverURI; - - @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - LanguageServerTarget that = (LanguageServerTarget) o; - return Objects.equals(languageName, that.languageName) && Objects.equals(serverURI, that.serverURI); - } - - @Override - public int hashCode() { - return Objects.hash(languageName, serverURI); - } - - LanguageServerTarget(String language, String port) { - languageName = language; - serverURI = URI.create(baseURI+ ":" + port); - } - - @Override - public String toString() { - return languageName; - } - } @BeforeAll public static void setup() { - // Wait for servers to start - for (LanguageServerTarget server : serverList) { - if (!serverListening(server.getServerURI())) { - throw new RuntimeException(String.format("Test Server for %s is not running at endpoint: %s", server.getLanguageName(), server.getServerURI())); - } - } - } - - public static boolean serverListening(URI uri) { - try (Socket ignored = new Socket(uri.getHost(), uri.getPort())) { - return true; - } catch (Exception e) { - e.printStackTrace(); - return false; - } - } - - static S3ECTestServerClient testServerClientFor(LanguageServerTarget server) { - S3ECTestServerApiService apiService = S3ECTestServerApiService.instance(); - ClientProtocol rest = new RestJsonClientProtocol(apiService.schema().id()); - return S3ECTestServerClient.builder() - .endpointResolver(EndpointResolver.staticEndpoint(server.serverURI)) - .withConfiguration(ClientConfig.builder() - .service(apiService) - .protocol(rest) - .endpointResolver(EndpointResolver.staticEndpoint(server.serverURI)) - .build()) - .build(); - } - - static Stream clientsForTest() { - return serverList.stream() - .map(LanguageServerTarget::getLanguageName) - .map(Arguments::of); - } - - static Stream crossLanguageClients() { - return serverList.stream() - .flatMap(t1 -> serverList.stream() - .flatMap(t2 -> Stream.of( - Arguments.of(t1, t2) - ))); - } - - /** - * Annoyingly, Smithy doesn't provide an interface for map types - * in HTTP headers, so we have to do the serde ourselves - * Servers need an equivalent utility. - * TODO: Move to a utilities class or something. - */ - private List metadataMapToList(Map md) { - List mdAsList = new ArrayList<>(md.size()); - for (Map.Entry keyValue : md.entrySet()) { - // Using ":" because Smithy will parse "," into a flattened list - mdAsList.add("[" + keyValue.getKey() + "]:[" + keyValue.getValue() + "]"); - } - return mdAsList; + validateServersRunning(); } @ParameterizedTest(name = "{displayName} for Encrypt: {0}, Decrypt: {1}") - @MethodSource("crossLanguageClients") + @MethodSource("software.amazon.encryption.s3.TestUtils#crossLanguageClients") public void crossLanguageTestKms(LanguageServerTarget encLang, LanguageServerTarget decLang) { S3ECTestServerClient encClient = testServerClientFor(encLang); - final String objectKey = "cross-lang-test-key-" + encLang; + final String objectKey = appendTestSuffix("cross-lang-test-key-" + encLang); final String input = "simple-test-input"; KeyMaterial kmsKeyArn = KeyMaterial.builder() .kmsKeyId(KMS_KEY_ARN) .build(); CreateClientOutput encClientOutput = encClient.createClient(CreateClientInput.builder() - .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn).build()) + .config(S3ECConfig + .builder() + .keyMaterial(kmsKeyArn) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .build() + ) .build()); String encS3ECId = encClientOutput.getClientId(); encClient.putObject(PutObjectInput.builder() @@ -195,7 +87,11 @@ public void crossLanguageTestKms(LanguageServerTarget encLang, LanguageServerTar S3ECTestServerClient decClient = testServerClientFor(decLang); CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder() .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn).build()) + .keyMaterial(kmsKeyArn) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .build() + ) .build()); String decS3ECId = decClientOutput.getClientId(); GetObjectOutput output = decClient.getObject(GetObjectInput.builder() @@ -210,10 +106,13 @@ public void crossLanguageTestKms(LanguageServerTarget encLang, LanguageServerTar } @ParameterizedTest(name = "{displayName} for Encrypt: {0}, Decrypt: {1}") - @MethodSource("crossLanguageClients") + @MethodSource("software.amazon.encryption.s3.TestUtils#crossLanguageClients") public void crossLanguageTestKmsWithEncCtx(LanguageServerTarget encLang, LanguageServerTarget decLang) { + if (ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED.contains(encLang.getLanguageName())) { + return; + } S3ECTestServerClient encClient = testServerClientFor(encLang); - final String objectKey = "cross-lang-test-key-kms-ec-" + encLang; + final String objectKey = appendTestSuffix("cross-lang-test-key-kms-ec-" + encLang); final String input = "simple-test-input"; final Map encCtx = new HashMap<>(); encCtx.put("user-defined-enc-ctx-key", "user-defined-enc-ctx-value"); @@ -224,7 +123,11 @@ public void crossLanguageTestKmsWithEncCtx(LanguageServerTarget encLang, Languag .build(); CreateClientOutput encClientOutput = encClient.createClient(CreateClientInput.builder() .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn).build()) + .keyMaterial(kmsKeyArn) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .build() + ) .build()); String encS3ECId = encClientOutput.getClientId(); @@ -238,7 +141,11 @@ public void crossLanguageTestKmsWithEncCtx(LanguageServerTarget encLang, Languag S3ECTestServerClient decClient = testServerClientFor(decLang); CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder() .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn).build()) + .keyMaterial(kmsKeyArn) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .build() + ) .build()); String decS3ECId = decClientOutput.getClientId(); GetObjectOutput output = decClient.getObject(GetObjectInput.builder() @@ -254,10 +161,16 @@ public void crossLanguageTestKmsWithEncCtx(LanguageServerTarget encLang, Languag } @ParameterizedTest(name = "{displayName} for Encrypt: {0}, Decrypt: {1}") - @MethodSource("crossLanguageClients") - public void crossLanguageTestKmsWithEncCtxMismatchFails(LanguageServerTarget encLang, LanguageServerTarget decLang) { + @MethodSource("software.amazon.encryption.s3.TestUtils#crossLanguageClients") + public void crossLanguageTestKmsWithSubsetEncCtxFails(LanguageServerTarget encLang, LanguageServerTarget decLang) { + if (ENCRYPTION_CONTEXT_ON_DECRYPT_UNSUPPORTED.contains(decLang.getLanguageName())) { + return; + } + if (ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED.contains(encLang.getLanguageName())) { + return; + } S3ECTestServerClient encClient = testServerClientFor(encLang); - final String objectKey = "cross-lang-test-key-kms-ec-mismatch-fails" + encLang; + final String objectKey = appendTestSuffix("cross-lang-test-key-kms-ec-subset-fails" + encLang); final String input = "simple-test-input"; final Map encCtx = new HashMap<>(); encCtx.put("user-defined-enc-ctx-key", "user-defined-enc-ctx-value"); @@ -268,7 +181,10 @@ public void crossLanguageTestKmsWithEncCtxMismatchFails(LanguageServerTarget enc .build(); CreateClientOutput encClientOutput = encClient.createClient(CreateClientInput.builder() .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn).build()) + .keyMaterial(kmsKeyArn) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .build()) .build()); String encS3ECId = encClientOutput.getClientId(); @@ -282,7 +198,11 @@ public void crossLanguageTestKmsWithEncCtxMismatchFails(LanguageServerTarget enc S3ECTestServerClient decClient = testServerClientFor(decLang); CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder() .config(S3ECConfig.builder() - .keyMaterial(kmsKeyArn).build()) + .keyMaterial(kmsKeyArn) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .build() + ) .build()); String decS3ECId = decClientOutput.getClientId(); try { @@ -293,15 +213,83 @@ public void crossLanguageTestKmsWithEncCtxMismatchFails(LanguageServerTarget enc .build()); fail("Expected exception!"); } catch (S3EncryptionClientError e) { - assertTrue(e.getMessage().contains("Provided encryption context does not match information retrieved from S3")); + if (decLang.getLanguageName().equals(RUBY_V3) || decLang.getLanguageName().equals(RUBY_V2_TRANSITION)) { + assertTrue(e.getMessage().contains("Value of encryption context from envelope does not match the provided encryption context"), "Actual error: " + e.getMessage()); + } else { + assertTrue(e.getMessage().contains("Provided encryption context does not match information retrieved from S3"), "Actual error: " + e.getMessage()); + } + } + } + + @ParameterizedTest(name = "{displayName} for Encrypt: {0}, Decrypt: {1}") + @MethodSource("software.amazon.encryption.s3.TestUtils#crossLanguageClients") + public void crossLanguageTestKmsWithIncorrectEncCtxFails(LanguageServerTarget encLang, LanguageServerTarget decLang) { + if (ENCRYPTION_CONTEXT_ON_DECRYPT_UNSUPPORTED.contains(decLang.getLanguageName())) { + return; + } + S3ECTestServerClient encClient = testServerClientFor(encLang); + final String objectKey = appendTestSuffix("cross-lang-test-key-kms-ec-incorrect-fails" + encLang); + final String input = "simple-test-input"; + final Map encCtx = new HashMap<>(); + encCtx.put("user-defined-enc-ctx-key", "user-defined-enc-ctx-value"); + encCtx.put("user-defined-enc-ctx-key-2", "user-defined-enc-ctx-value-2"); + final List mdAsList = metadataMapToList(encCtx); + KeyMaterial kmsKeyArn = KeyMaterial.builder() + .kmsKeyId(KMS_KEY_ARN) + .build(); + CreateClientOutput encClientOutput = encClient.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 encS3ECId = encClientOutput.getClientId(); + + encClient.putObject(PutObjectInput.builder() + .clientID(encS3ECId) + .key(objectKey) + .bucket(BUCKET) + .metadata(mdAsList) + .body(ByteBuffer.wrap(input.getBytes(StandardCharsets.UTF_8))) + .build()); + S3ECTestServerClient decClient = 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(); + + final Map incorrectEncCtx = new HashMap<>(); + incorrectEncCtx.put("this-is-wrong-ec-key", "bad-value"); + var incorrectMdAsList = metadataMapToList(incorrectEncCtx); + try { + decClient.getObject(GetObjectInput.builder() + .clientID(decS3ECId) + .bucket(BUCKET) + .key(objectKey) + .metadata(incorrectMdAsList) + .build()); + fail("Expected exception!"); + } catch (S3EncryptionClientError e) { + if (decLang.getLanguageName().equals(RUBY_V3) || decLang.getLanguageName().equals(RUBY_V2_TRANSITION)) { + assertTrue(e.getMessage().contains("Value of encryption context from envelope does not match the provided encryption context"), "Actual error: " + e.getMessage()); + } else { + assertTrue(e.getMessage().contains("Provided encryption context does not match information retrieved from S3"), "Actual error: " + e.getMessage()); + } } } @ParameterizedTest(name = "{displayName} for Encrypt: Java, Decrypt: {0}") - @MethodSource("clientsForTest") - public void kmsV1Legacy(String language) { - S3ECTestServerClient client = testServerClientFor(serverMap.get(language)); - final String objectKey = "test-key-kms-v1-" + language; + @MethodSource("software.amazon.encryption.s3.TestUtils#clientsForTest") + public void kmsV1Legacy(TestUtils.LanguageServerTarget language) { + S3ECTestServerClient client = testServerClientFor(language); + final String objectKey = appendTestSuffix("test-key-kms-v1-" + language); final String input = "simple-test-input"; KeyMaterial kmsKeyArn = KeyMaterial.builder() .kmsKeyId(KMS_KEY_ARN) @@ -310,6 +298,8 @@ public void kmsV1Legacy(String language) { .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(); @@ -340,10 +330,10 @@ public void kmsV1Legacy(String language) { } @ParameterizedTest(name = "{displayName} for Encrypt: Java, Decrypt: {0}") - @MethodSource("clientsForTest") - public void kmsV1LegacyWithEncCtx(String language) { - S3ECTestServerClient client = testServerClientFor(serverMap.get(language)); - final String objectKey = "test-key-kms-v1-with-enc-ctx-" + language; + @MethodSource("software.amazon.encryption.s3.TestUtils#clientsForTest") + public void kmsV1LegacyWithEncCtx(TestUtils.LanguageServerTarget language) { + S3ECTestServerClient client = testServerClientFor(language); + final String objectKey = appendTestSuffix("test-key-kms-v1-with-enc-ctx-" + language); final String input = "simple-test-input"; KeyMaterial kmsKeyArn = KeyMaterial.builder() .kmsKeyId(KMS_KEY_ARN) @@ -352,6 +342,8 @@ public void kmsV1LegacyWithEncCtx(String language) { .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(); @@ -389,10 +381,10 @@ public void kmsV1LegacyWithEncCtx(String language) { } @ParameterizedTest(name = "{displayName} for Encrypt: Java, Decrypt: {0}") - @MethodSource("clientsForTest") - public void kmsV1LegacyFailsWhenLegacyDisabled(String language) { - S3ECTestServerClient client = testServerClientFor(serverMap.get(language)); - final String objectKey = "test-key-kms-v1-fails-disabled" + language; + @MethodSource("software.amazon.encryption.s3.TestUtils#clientsForTest") + public void kmsV1LegacyFailsWhenLegacyDisabled(TestUtils.LanguageServerTarget language) { + S3ECTestServerClient client = testServerClientFor(language); + final String objectKey = appendTestSuffix("test-key-kms-v1-fails-disabled" + language); final String input = "simple-test-input"; KeyMaterial kmsKeyArn = KeyMaterial.builder() .kmsKeyId(KMS_KEY_ARN) @@ -401,6 +393,8 @@ public void kmsV1LegacyFailsWhenLegacyDisabled(String language) { .config(S3ECConfig.builder() .enableLegacyWrappingAlgorithms(false) .keyMaterial(kmsKeyArn) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) .build()) .build()); String s3ECId = output1.getClientId(); @@ -429,8 +423,261 @@ public void kmsV1LegacyFailsWhenLegacyDisabled(String language) { .build()); fail("Expected Exception"); } catch (S3EncryptionClientError e) { - assertTrue(e.getMessage().contains("Enable legacy wrapping algorithms to use legacy key wrapping algorithm: kms")); + if (language.getLanguageName().equals(NET_V3_TRANSITION) || language.getLanguageName().equals(NET_V4) + || language.getLanguageName().equals(CPP_V2_TRANSITION) || language.getLanguageName().equals(CPP_V3)) { + assertTrue(e.getMessage().contains( + "The requested object is encrypted with V1 encryption schemas that have been disabled by client configuration" + ), "Actual error:" + e.getMessage()); + } else if (language.getLanguageName().equals(RUBY_V3) || language.getLanguageName().equals(RUBY_V2_TRANSITION)) { + assertTrue(e.getMessage().contains( + "The requested object is encrypted with V1 encryption schemas that have been disabled by client configuration security_profile = :v2. Retry with :v2_and_legacy or re-encrypt the object." + ), "Actual error:" + e.getMessage()); + } else if (language.getLanguageName().equals(PHP_V3)) { + assertTrue(e.getMessage().contains("The requested object is encrypted with V1 encryption schemas that have been disabled by client configuration @SecurityProfile=V3. Retry with V3_AND_LEGACY enabled or reencrypt the object."), "Actual error: " + e.getMessage()); + } else { + assertTrue(e.getMessage().contains("Enable legacy wrapping algorithms to use legacy key wrapping algorithm: kms"), "Actual error: " + e.getMessage()); + } + } + } + + @ParameterizedTest(name = "{displayName} for Encrypt: {0}, Decrypt: {1}") + @MethodSource("software.amazon.encryption.s3.TestUtils#crossLanguageClients") + public void rsaRoundTrip(LanguageServerTarget encLang, LanguageServerTarget decLang) throws Exception { + if (!RAW_SUPPORTED.contains(encLang.getLanguageName())) { + throw new TestAbortedException("not encrypting raw keyrings with: " + encLang.getLanguageName()); + } + if (!RAW_SUPPORTED.contains(decLang.getLanguageName())) { + throw new TestAbortedException("not decrypting raw keyrings with: " + decLang.getLanguageName()); + } + S3ECTestServerClient encClient = testServerClientFor(encLang); + S3ECTestServerClient decClient = testServerClientFor(decLang); + final String objectKey = appendTestSuffix(String.format("rsa-write-%s-read-%s", encLang.getLanguageName(), decLang.getLanguageName())); + final String input = "simple-test-input-rsa"; + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(2048); + KeyPair RSA_KEY_PAIR_1 = keyPairGen.generateKeyPair(); + + KeyMaterial rsaKeyOne = KeyMaterial.builder() + .rsaKey(ByteBuffer.wrap(RSA_KEY_PAIR_1.getPrivate().getEncoded())) + .build(); + CreateClientOutput encClientOutput = encClient.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + // TODO: use this for now to satisfy current. think about long term soln for this + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .keyMaterial(rsaKeyOne).build()) + .build()); + String encS3ECId = encClientOutput.getClientId(); + CreateClientOutput decClientOutput = decClient.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .keyMaterial(rsaKeyOne).build()) + .build()); + String decS3ECId = decClientOutput.getClientId(); + encClient.putObject(PutObjectInput.builder() + .clientID(encS3ECId) + .key(objectKey) + .bucket(BUCKET) + .body(ByteBuffer.wrap(input.getBytes(StandardCharsets.UTF_8))) + .build()); + GetObjectOutput output = decClient.getObject(GetObjectInput.builder() + .clientID(decS3ECId) + .bucket(BUCKET) + .key(objectKey) + .build()); + assertEquals(input, new String(output.getBody().array())); + } + + @ParameterizedTest(name = "{displayName} for Encrypt: Java, Decrypt: {0}") + @MethodSource("software.amazon.encryption.s3.TestUtils#clientsForTest") + public void instructionFileReadV2Format(TestUtils.LanguageServerTarget language) { + if (KMS_INSTRUCTION_FILE_UNSUPPORTED.contains(language.getLanguageName())) { + throw new TestAbortedException(String.format("%s does not support KMS instruction files", language.getLanguageName())); } + if (INSTRUCTION_FILE_GET_UNSUPPORTED.contains(language.getLanguageName())) { + throw new TestAbortedException(String.format("%s does not support instruction file Gets", language.getLanguageName())); + } + S3ECTestServerClient client = testServerClientFor(language); + final String objectKey = appendTestSuffix("read-instruction-file-v2-" + language); + final String input = "simple-test-input"; + KeyMaterial kmsKeyArn = KeyMaterial.builder() + .kmsKeyId(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(); + + // Write with instruction file using V2 client + EncryptionMaterialsProvider materialsProvider = new KMSEncryptionMaterialsProvider(KMS_KEY_ARN); + CryptoConfigurationV2 cryptoConfigurationV2 = new CryptoConfigurationV2(); + cryptoConfigurationV2.setStorageMode(CryptoStorageMode.InstructionFile); + AmazonS3EncryptionV2 v2Client = AmazonS3EncryptionClientV2.encryptionBuilder() + .withEncryptionMaterialsProvider(materialsProvider) + .withCryptoConfiguration(cryptoConfigurationV2) + .build(); + v2Client.putObject(BUCKET, objectKey, input); + + // Read should be enabled by default + GetObjectOutput output = client.getObject(GetObjectInput.builder() + .clientID(s3ECId) + .bucket(BUCKET) + .key(objectKey) + .build()); + + assertEquals(input, new String(output.getBody().array())); } + @ParameterizedTest(name = "{displayName} for Encrypt: {0}, Decrypt: {1}") + @MethodSource("software.amazon.encryption.s3.TestUtils#crossLanguageClients") + public void instructionFileWriteAndRead(LanguageServerTarget encLang, LanguageServerTarget decLang) throws Exception { + if (INSTRUCTION_FILE_PUT_UNSUPPORTED.contains(encLang.getLanguageName())) { + throw new TestAbortedException("not testing " + encLang.getLanguageName()); + } + if (INSTRUCTION_FILE_GET_UNSUPPORTED.contains(decLang.getLanguageName())) { + throw new TestAbortedException("not testing " + encLang.getLanguageName()); + } + if (KMS_INSTRUCTION_FILE_UNSUPPORTED.contains(encLang.getLanguageName())) { + throw new TestAbortedException("not testing " + encLang.getLanguageName()); + } + if (KMS_INSTRUCTION_FILE_UNSUPPORTED.contains(decLang.getLanguageName())) { + throw new TestAbortedException("not testing " + encLang.getLanguageName()); + } + S3ECTestServerClient encClient = testServerClientFor(encLang); + S3ECTestServerClient decClient = testServerClientFor(decLang); + final String objectKey = appendTestSuffix(String.format("write-%s-read-%s-instruction-file", encLang.getLanguageName(), decLang.getLanguageName())); + final String input = "simple-test-input"; + KeyMaterial kmsKeyArn = KeyMaterial.builder() + .kmsKeyId(KMS_KEY_ARN) + .build(); + CreateClientOutput encOutput = encClient.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(kmsKeyArn) + .instructionFileConfig(InstructionFileConfig.builder() + .enableInstructionFilePutObject(true) + .build()) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .build()) + .build()); + String encS3ECId = encOutput.getClientId(); + CreateClientOutput decOutput = 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 = decOutput.getClientId(); + + // Write with instruction file + encClient.putObject(PutObjectInput.builder() + .clientID(encS3ECId) + .bucket(BUCKET) + .key(objectKey) + .body(ByteBuffer.wrap(input.getBytes(StandardCharsets.UTF_8))) + .build()); + + // Assert using Java plaintext client that an instruction file exists + ResponseBytes ptInstFile; + try (S3Client ptS3Client = S3Client.create()) { + ptInstFile = ptS3Client.getObjectAsBytes(builder -> builder + .bucket(BUCKET) + .key(objectKey + ".instruction") + .build()); + } + // Check for inst file key + if (!encLang.getLanguageName().startsWith("Ruby") && !encLang.getLanguageName().startsWith("PHP")) { + // Ruby and PHP do not include it :( + assertTrue(ptInstFile.response().metadata().containsKey("x-amz-crypto-instr-file")); + } + + // 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); + + assertFalse(ptInstFile.asUtf8String().isEmpty()); + // Read should be enabled by default + GetObjectOutput output = decClient.getObject(GetObjectInput.builder() + .clientID(decS3ECId) + .bucket(BUCKET) + .key(objectKey) + .build()); + + assertEquals(input, new String(output.getBody().array())); + } + + @ParameterizedTest(name = "{displayName} for Encrypt: {0}, Decrypt: {1}") + @MethodSource("software.amazon.encryption.s3.TestUtils#crossLanguageClients") + public void instructionFileWriteAndReadWithRSA(LanguageServerTarget encLang, LanguageServerTarget decLang) throws Exception { + // Early validation + if (!RAW_SUPPORTED.contains(encLang.getLanguageName())) { + throw new TestAbortedException("not encrypting raw keyring with: " + encLang.getLanguageName()); + } + if (!RAW_SUPPORTED.contains(decLang.getLanguageName())) { + throw new TestAbortedException("not decrypting raw keyring with: " + decLang.getLanguageName()); + } + + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(2048); + KeyMaterial rsaKeyMaterial = KeyMaterial.builder() + .rsaKey(ByteBuffer.wrap(keyPairGen.generateKeyPair().getPrivate().getEncoded())) + .build(); + + S3ECConfig config = S3ECConfig.builder() + .instructionFileConfig(InstructionFileConfig.builder() + .enableInstructionFilePutObject(true) + .build()) + .encryptionAlgorithm(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF) + .commitmentPolicy(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .keyMaterial(rsaKeyMaterial) + .build(); + + // Create clients + S3ECTestServerClient encClient = testServerClientFor(encLang); + S3ECTestServerClient decClient = testServerClientFor(decLang); + + String encS3ECId = encClient.createClient(CreateClientInput.builder().config(config).build()).getClientId(); + String decS3ECId = decClient.createClient(CreateClientInput.builder().config(config).build()).getClientId(); + + final String objectKey = appendTestSuffix(String.format("rsa-insfile-write-%s-read-%s", + encLang.getLanguageName(), decLang.getLanguageName())); + final String input = "simple-test-input-rsa"; + + // Encrypt + encClient.putObject(PutObjectInput.builder() + .clientID(encS3ECId) + .bucket(BUCKET) + .key(objectKey) + .body(ByteBuffer.wrap(input.getBytes(StandardCharsets.UTF_8))) + .build()); + + // Assert using Java plaintext client that an instruction file exists + ResponseBytes ptInstFile; + try (S3Client ptS3Client = S3Client.create()) { + ptInstFile = ptS3Client.getObjectAsBytes(builder -> builder + .bucket(BUCKET) + .key(objectKey + ".instruction") + .build()); + } + // assertTrue(ptInstFile.response().metadata().containsKey("x-amz-crypto-instr-file")); + assertFalse(ptInstFile.asUtf8String().isEmpty()); + // Read should be enabled by default + GetObjectOutput output = decClient.getObject(GetObjectInput.builder() + .clientID(decS3ECId) + .bucket(BUCKET) + .key(objectKey) + .build()); + + assertEquals(input, new String(output.getBody().array())); + } } diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/TestUtils.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/TestUtils.java new file mode 100644 index 00000000..2b9cd062 --- /dev/null +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/TestUtils.java @@ -0,0 +1,812 @@ +/* + * 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.fail; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +import java.net.Socket; +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.amazonaws.services.s3.model.S3Object; +import com.fasterxml.jackson.databind.ObjectMapper; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.amazonaws.services.s3.model.ObjectMetadata; +import org.joda.time.DateTime; +import org.joda.time.format.DateTimeFormat; +import org.junit.jupiter.params.provider.Arguments; +import com.amazonaws.regions.Region; +import com.amazonaws.regions.Regions; +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.PutObjectOutput; +import software.amazon.encryption.s3.model.S3EncryptionClientError; +import software.amazon.smithy.java.aws.client.restjson.RestJsonClientProtocol; +import software.amazon.smithy.java.client.core.ClientConfig; +import software.amazon.smithy.java.client.core.ClientProtocol; +import software.amazon.smithy.java.client.core.endpoint.EndpointResolver; +import software.amazon.encryption.s3.client.S3ECTestServerClient; +import software.amazon.encryption.s3.model.S3ECTestServerApiService; +import software.amazon.smithy.java.http.api.HttpRequest; +import software.amazon.smithy.java.http.api.HttpResponse; + +public class TestUtils { + + // Version name constants + // Each language can have up to 3 versions: + // vN-Current: Currently released version. Does not support setting commitment policy. + // vN-Transition: Proposed feature release version. Supports reading messages encrypted with key commitment. + // vN+1: Proposed breaking release version. Supports reading/writing messages encrypted with key commitment. + + public static final String JAVA_V3_TRANSITION = "Java-V3-Transition"; + public static final String JAVA_V4 = "Java-V4"; + + // No Python S3EC versions are released. Only test V3 as the "vN+1" version. + public static final String PYTHON_V3 = "Python-V3"; + + public static final String GO_V3_TRANSITION = "Go-V3-Transition"; + public static final String GO_V4 = "Go-V4"; + + public static final String NET_V2_TRANSITION = "NET-V2-Transition"; + public static final String NET_V3_TRANSITION = "NET-V3-Transition"; + public static final String NET_V4 = "NET-V4"; + + public static final String CPP_V2_TRANSITION = "CPP-V2-Transition"; + public static final String CPP_V3 = "CPP-V3"; + + public static final String RUBY_V2_TRANSITION = "Ruby-V2-Transition"; + public static final String RUBY_V3 = "Ruby-V3"; + + public static final String PHP_V2_TRANSITION = "PHP-V2-Transition"; + public static final String PHP_V3 = "PHP-V3"; + + // Test configuration constants + public static final String KMS_KEY_ARN = System.getenv("TEST_SERVER_KMS_KEY_ARN") != null ? + System.getenv("TEST_SERVER_KMS_KEY_ARN") : "arn:aws:kms:us-west-2:370957321024:alias/S3EC-Test-Server-Github-KMS-Key"; + public static final Region KMS_REGION = Region.getRegion(Regions.fromName("us-west-2")); + public static final String BUCKET = System.getenv("TEST_SERVER_S3_BUCKET") != null ? + System.getenv("TEST_SERVER_S3_BUCKET") : "s3ec-test-server-github-bucket"; + + // Sets of unsupported features by language + public static final Set ENCRYPTION_CONTEXT_ON_DECRYPT_UNSUPPORTED = + Set.of(PHP_V2_TRANSITION, PHP_V3, NET_V3_TRANSITION, NET_V4); + + public static final Set ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED = + Set.of(NET_V3_TRANSITION, NET_V4); + + public static final Set RE_ENCRYPT_SUPPORTED = + Set.of(JAVA_V3_TRANSITION, JAVA_V4); + + public static final Set RANGED_GETS_SUPPORTED = + Set.of( + JAVA_V3_TRANSITION, JAVA_V4, CPP_V2_TRANSITION, CPP_V3 + ); + + // Cpp only supports Raw AES + public static final Set RAW_AES_SUPPORTED = + Set.of(JAVA_V3_TRANSITION, JAVA_V4, NET_V3_TRANSITION, NET_V4, RUBY_V2_TRANSITION, RUBY_V3, CPP_V2_TRANSITION, CPP_V3); + + public static final Set RAW_RSA_SUPPORTED = + Set.of(JAVA_V3_TRANSITION, JAVA_V4, NET_V3_TRANSITION, NET_V4, RUBY_V2_TRANSITION, RUBY_V3); + + // Intersection of RAW_AES_SUPPORTED and RAW_RSA_SUPPORTED + public static final Set RAW_SUPPORTED = + RAW_AES_SUPPORTED.stream() + .filter(RAW_RSA_SUPPORTED::contains) + .collect(Collectors.toSet()); + + // .NET only supports decrypting instruction files using AES and RSA. + // Python MUST support decrypting KMS instruction files, but does not yet. + public static final Set KMS_INSTRUCTION_FILE_UNSUPPORTED = + Set.of(NET_V2_TRANSITION, NET_V3_TRANSITION, NET_V4); + + // Go does not write with instruction files + public static final Set INSTRUCTION_FILE_PUT_UNSUPPORTED = + Set.of(GO_V3_TRANSITION, GO_V4, PYTHON_V3); + + // Not implemented yet in Python. + public static final Set INSTRUCTION_FILE_GET_UNSUPPORTED = + Set.of(PYTHON_V3); + + // Languages that support custom instruction file suffix on GetObject + // Only Java, Ruby, and PHP servers have been updated with this feature + // This is a current gap. + public static final Set CUSTOM_INSTRUCTION_SUFFIX_GET_SUPPORTED = + Set.of( + JAVA_V3_TRANSITION, + JAVA_V4, + RUBY_V2_TRANSITION, + RUBY_V3, + PHP_V2_TRANSITION, + PHP_V3 + ); + + public static final Set TRANSITION_VERSIONS = + Set.of( + JAVA_V3_TRANSITION, + GO_V3_TRANSITION, + NET_V3_TRANSITION, + CPP_V2_TRANSITION, + PHP_V2_TRANSITION, + RUBY_V2_TRANSITION + ); + + public static final Set IMPROVED_VERSIONS = + Set.of( + JAVA_V4, + // PYTHON_V3, + GO_V4, + NET_V4, + CPP_V3, + PHP_V3, + RUBY_V3 + ); + + private static final Map serverMap; + + static { + final Map servers = new LinkedHashMap<>(); + servers.put(PYTHON_V3, new LanguageServerTarget(PYTHON_V3, "8081")); + servers.put(CPP_V2_TRANSITION, new LanguageServerTarget(CPP_V2_TRANSITION, "8097")); + servers.put(CPP_V3, new LanguageServerTarget(CPP_V3, "8091")); + servers.put(GO_V4, new LanguageServerTarget(GO_V4, "8089")); + servers.put(NET_V4, new LanguageServerTarget(NET_V4, "8090")); + servers.put(RUBY_V3, new LanguageServerTarget(RUBY_V3, "8092")); + servers.put(PHP_V3, new LanguageServerTarget(PHP_V3, "8093")); + servers.put(JAVA_V3_TRANSITION, new LanguageServerTarget(JAVA_V3_TRANSITION, "8094")); + servers.put(GO_V3_TRANSITION, new LanguageServerTarget(GO_V3_TRANSITION, "8095")); + servers.put(RUBY_V2_TRANSITION, new LanguageServerTarget(RUBY_V2_TRANSITION, "8098")); + servers.put(PHP_V2_TRANSITION, new LanguageServerTarget(PHP_V2_TRANSITION, "8099")); + servers.put(JAVA_V4, new LanguageServerTarget(JAVA_V4, "8088")); + servers.put(NET_V3_TRANSITION, new LanguageServerTarget(NET_V3_TRANSITION, "8100")); + serverMap = filterServers(servers); + + System.out.println("=== Configured Test Servers ==="); + System.out.println("\nServers:"); + serverMap.forEach((name, target) -> { + System.out.println(" " + name + " -> " + target.getServerURI()); + }); + System.out.println("\nTotal servers configured: " + serverMap.size()); + System.out.println("================================"); + } + + public static class LanguageServerTarget { + private final String baseURI = "http://localhost"; + private String languageName; + private URI serverURI; + + public LanguageServerTarget(String language, String port) { + languageName = language; + serverURI = URI.create(baseURI + ":" + port); + } + + public String getLanguageName() { + return languageName; + } + + public URI getServerURI() { + return serverURI; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + LanguageServerTarget that = (LanguageServerTarget) o; + return Objects.equals(languageName, that.languageName) && Objects.equals(serverURI, that.serverURI); + } + + @Override + public int hashCode() { + return Objects.hash(languageName, serverURI); + } + + @Override + public String toString() { + return languageName; + } + } + + /** + * Filters the available servers based on system property test.filter.servers + * @param allServers Map of all available servers + * @return Filtered map of servers to use for testing + */ + private static Map filterServers(Map allServers) { + final String maybeFilter = System.getProperty("test.filter.servers"); + if (maybeFilter == null || maybeFilter.trim().isEmpty()) { + return allServers; // No filtering - use all servers + } + + System.out.println("Filtering with: " + maybeFilter); + + final String[] filters = Arrays.stream(maybeFilter.split(",")) + .map(String::trim) + .map(String::toLowerCase) + .toArray(String[]::new); + + return allServers.entrySet().stream() + .filter(entry -> { + String key = entry.getKey().toLowerCase(); + System.out.println("Checking server name:" + key); + return Arrays.stream(filters).anyMatch(key::contains); + }) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (e1, e2) -> e1, // merge function (not really needed) + LinkedHashMap::new // preserve order + )); + } + + /** + * Gets the map of available server targets for testing + * @return Map of language names to server targets + */ + public static Map getServerMap() { + return serverMap; + } + + /** + * Checks if a server is listening on the specified URI + * @param uri The URI to check + * @return true if server is listening, false otherwise + */ + public static boolean serverListening(URI uri) { + try (Socket ignored = new Socket(uri.getHost(), uri.getPort())) { + return true; + } catch (Exception e) { + e.printStackTrace(); + return false; + } + } + + /** + * Creates a test server client for the specified language server target + * @param server The language server target + * @return Configured S3ECTestServerClient + */ + public static S3ECTestServerClient testServerClientFor(LanguageServerTarget server) { + S3ECTestServerApiService apiService = S3ECTestServerApiService.instance(); + ClientProtocol rest = new RestJsonClientProtocol(apiService.schema().id()); + return S3ECTestServerClient.builder() + .endpointResolver(EndpointResolver.staticEndpoint(server.serverURI)) + .withConfiguration(ClientConfig.builder() + .service(apiService) + .protocol(rest) + .endpointResolver(EndpointResolver.staticEndpoint(server.serverURI)) + .build()) + .build(); + } + + /** + * Converts a metadata map to a list format for Smithy serialization + * Annoyingly, Smithy doesn't provide an interface for map types + * in HTTP headers, so we have to do the serde ourselves + * @param md The metadata map + * @return List representation of the metadata + */ + public static List metadataMapToList(Map md) { + List mdAsList = new ArrayList<>(md.size()); + for (Map.Entry keyValue : md.entrySet()) { + // Using ":" because Smithy will parse "," into a flattened list + mdAsList.add("[" + keyValue.getKey() + "]:[" + keyValue.getValue() + "]"); + } + return mdAsList; + } + + /** + * Validates that all servers in the server map are running + * @throws RuntimeException if any server is not running + */ + public static void validateServersRunning() { + for (LanguageServerTarget server : serverMap.values()) { + if (!serverListening(server.getServerURI())) { + throw new RuntimeException(String.format("Test Server for %s is not running at endpoint: %s", + server.getLanguageName(), server.getServerURI())); + } + } + } + + /** + * Provides a stream of arguments for parameterized tests that test individual clients + * @return Stream of Arguments containing language names for testing + */ + public static Stream clientsForTest() { + return serverMap.values().stream() + .map(Arguments::of); + } + + /** + * Get stream of arguments for transition version clients for testing. + */ + public static Stream transitionClientsForTest() { + return serverMap.values().stream() + .filter(target -> TRANSITION_VERSIONS.contains(target.getLanguageName())) + .map(Arguments::of); + } + + /** + * Get stream of arguments for improved version clients for testing. + */ + public static Stream improvedClientsForTest() { + return serverMap.values().stream() + .filter(target -> IMPROVED_VERSIONS.contains(target.getLanguageName())) + .map(Arguments::of); + } + + /** + * Get stream of arguments for clients that support RAW AES (includes CPP). + */ + public static Stream clientsRawAesForTest() { + 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); + } + + /** + * Get stream of arguments for clients that support RAW RSA (excludes CPP). + */ + public static Stream clientsRawRsaForTest() { + 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); + } + + /** + * These functions provide a stream of arguments for parameterized tests. + * @return Stream of Arguments containing pairs of LanguageServerTarget for encryption and decryption + */ + public static Stream encryptImprovedDecryptImproved() { + return improvedClientsForTest() + .flatMap(encrypt -> improvedClientsForTest() + .flatMap(decrypt -> Stream.of( + Arguments.of(encrypt.get()[0], decrypt.get()[0]) + ))); + } + + public static Stream encryptImprovedDecryptTransition() { + return improvedClientsForTest() + .flatMap(encrypt -> transitionClientsForTest() + .flatMap(decrypt -> Stream.of( + Arguments.of(encrypt.get()[0], decrypt.get()[0]) + ))); + } + + public static Stream encryptTransitionDecryptImproved() { + return transitionClientsForTest() + .flatMap(encrypt -> improvedClientsForTest() + .flatMap(decrypt -> Stream.of( + Arguments.of(encrypt.get()[0], decrypt.get()[0]) + ))); + } + + /** + * Provides a stream of arguments for parameterized tests that test cross-language compatibility + * @return Stream of Arguments containing pairs of LanguageServerTarget for encryption and decryption + */ + public static Stream crossLanguageClients() { + return serverMap.values().stream() + .flatMap(t1 -> serverMap.values().stream() + .flatMap(t2 -> Stream.of( + Arguments.of(t1, t2) + ))); + } + + /** + * For a given string, append a suffix to distinguish it from + * simultaneous test runs. + * @param s The string to append the suffix to + * @return The string with the suffix appended + */ + public static String appendTestSuffix(final String s) { + StringBuilder stringBuilder = new StringBuilder(s); + stringBuilder.append(DateTimeFormat.forPattern("-yyMMdd-hhmmss-").print(new DateTime())); + stringBuilder.append((int) (Math.random() * 100000)); + return stringBuilder.toString(); + } + + private static AmazonS3 s3Client = AmazonS3ClientBuilder.defaultClient(); + public static EncryptionAlgorithm GetEncryptionAlgorithm(String objectKey) + { + // Lambda to determine encryption algorithm from a metadata map + java.util.function.Function, Optional> getAlgorithmFromMap = (map) -> { + if (map.containsKey("x-amz-c")) { + return Optional.of(EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY); + } else if (map.containsKey("x-amz-cek-alg")) { + String cek = (String) map.get("x-amz-cek-alg"); + if (cek.contains("CBC")) { + return Optional.of(EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF); + } else if (cek.contains("GCM")) { + return Optional.of(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF); + } + } + return Optional.empty(); + }; + + ObjectMetadata metadata = s3Client.getObjectMetadata(TestUtils.BUCKET, objectKey); + Map userMetadata = metadata.getUserMetadata(); + + // Try to get algorithm from object metadata + Optional algorithm = getAlgorithmFromMap.apply(userMetadata); + if (algorithm.isPresent()) { + return algorithm.get(); + } + + // Check instruction file + try { + String instructionFileKey = objectKey + ".instruction"; + com.amazonaws.services.s3.model.S3Object instructionFileObject = + s3Client.getObject(TestUtils.BUCKET, instructionFileKey); + + // Read instruction file content + java.io.InputStream inputStream = instructionFileObject.getObjectContent(); + String instructionFileJson = new String( + inputStream.readAllBytes(), + java.nio.charset.StandardCharsets.UTF_8 + ); + inputStream.close(); + + // Parse JSON to get metadata + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + Map instructionFileMap = mapper.readValue(instructionFileJson, Map.class); + + // Try to get algorithm from instruction file + algorithm = getAlgorithmFromMap.apply(instructionFileMap); + if (algorithm.isPresent()) { + return algorithm.get(); + } + } catch (Exception e) { + // Instruction file doesn't exist or couldn't be read + } + + throw new RuntimeException("Could not determine encryption algorithm from object metadata or instruction file!"); + } + + public static void Encrypt( + S3ECTestServerClient client, + String S3ECId, + String objectKey, + List crossLanguageObjects, + EncryptionAlgorithm expectedEncryptionAlgorithm + ) { + PutObjectOutput foo = client.putObject(PutObjectInput.builder() + .clientID(S3ECId) + .key(objectKey) + .bucket(TestUtils.BUCKET) + .body(ByteBuffer.wrap(objectKey.getBytes(StandardCharsets.UTF_8))) + .build()); + + assertEquals( + expectedEncryptionAlgorithm, + GetEncryptionAlgorithm(objectKey), + "When encrypting the EncryptionAlgorithm does not match the expected value: " + expectedEncryptionAlgorithm + ); + + crossLanguageObjects.add(objectKey); + } + + public static void Decrypt( + S3ECTestServerClient client, + String S3ECId, + List crossLanguageObjects, + EncryptionAlgorithm expectedEncryptionAlgorithm + ) { + // Call 5-parameter version with crossLanguageObjects as expectedPlaintexts + Decrypt(client, S3ECId, crossLanguageObjects, expectedEncryptionAlgorithm, crossLanguageObjects); + } + + public static void Decrypt( + S3ECTestServerClient client, + String S3ECId, + List crossLanguageObjects, + EncryptionAlgorithm expectedEncryptionAlgorithm, + List expectedPlaintexts + ) { + Decrypt(client, S3ECId, crossLanguageObjects, expectedEncryptionAlgorithm, expectedPlaintexts, null); + } + + public static void Decrypt( + S3ECTestServerClient client, + String S3ECId, + List crossLanguageObjects, + EncryptionAlgorithm expectedEncryptionAlgorithm, + List expectedPlaintexts, + String instructionFileSuffix + ) { + if (crossLanguageObjects.isEmpty()) { + fail("There is nothing to decrypt"); + } + + List failures = new ArrayList<>(); + for (int i = 0; i < crossLanguageObjects.size(); i++) { + try { + String objectKey = crossLanguageObjects.get(i); + String expectedPlaintext = expectedPlaintexts.get(i); + + GetObjectInput.Builder builder = GetObjectInput.builder() + .clientID(S3ECId) + .bucket(TestUtils.BUCKET) + .key(objectKey); + + // Add custom instruction file suffix if provided + if (instructionFileSuffix != null && !instructionFileSuffix.isEmpty()) { + builder.instructionFileSuffix(instructionFileSuffix); + } + + GetObjectOutput output = client.getObject(builder.build()); + + // Then: Pass + assertEquals(expectedPlaintext, new String(output.getBody().array())); + assertEquals( + expectedEncryptionAlgorithm, + GetEncryptionAlgorithm(objectKey), + "When decrypting the EncryptionAlgorithm does not match the expected value: " + expectedEncryptionAlgorithm + ); + } catch (Exception e) { + failures.add(String.format( + "Failed to decrypt object '%s' (index %d): %s - %s", + crossLanguageObjects.get(i), i, e.getClass().getSimpleName(), e.getMessage() + )); + } + } + + if (!failures.isEmpty()) { + throw new AssertionError(String.format( + "Decryption failed for %d out of %d objects:\n%s", + failures.size(), crossLanguageObjects.size(), + String.join("\n", failures) + )); + } + } + + /** + * Decrypt helper for C++ clients that require materials description per-operation. + * + * C++ SDK Design: Unlike Java/. NET/etc where materials description is embedded in the + * keyring during client creation, the C++ SDK requires passing materials description + * as a contextMap parameter to each GetObject/PutObject operation. + * + * This helper extracts materials description from KeyMaterial and passes it via the + * Content-Metadata header on each GetObject call, which the C++ server converts to + * the contextMap parameter required by the C++ SDK. + */ + public static void DecryptWithMaterialsDescription( + S3ECTestServerClient client, + String S3ECId, + List crossLanguageObjects, + KeyMaterial keyMaterial, + EncryptionAlgorithm expectedEncryptionAlgorithm + ) { + DecryptWithMaterialsDescription(client, S3ECId, crossLanguageObjects, keyMaterial, + expectedEncryptionAlgorithm, crossLanguageObjects); + } + + /** + * Decrypt helper for C++ clients with custom expected plaintexts. + */ + public static void DecryptWithMaterialsDescription( + S3ECTestServerClient client, + String S3ECId, + List crossLanguageObjects, + KeyMaterial keyMaterial, + EncryptionAlgorithm expectedEncryptionAlgorithm, + List expectedPlaintexts + ) { + if (crossLanguageObjects.isEmpty()) { + throw new AssertionError("There is nothing to decrypt"); + } + + // Extract materials description from KeyMaterial + List metadata = (keyMaterial.getMaterialsDescription() != null) + ? metadataMapToList(keyMaterial.getMaterialsDescription()) + : new ArrayList<>(); + + List failures = new ArrayList<>(); + for (int i = 0; i < crossLanguageObjects.size(); i++) { + try { + String objectKey = crossLanguageObjects.get(i); + String expectedPlaintext = expectedPlaintexts.get(i); + + GetObjectOutput output = client.getObject(GetObjectInput.builder() + .clientID(S3ECId) + .bucket(TestUtils.BUCKET) + .key(objectKey) + .metadata(metadata) // Pass materials description for C++ + .build()); + + // Then: Pass + assertEquals(expectedPlaintext, new String(output.getBody().array())); + assertEquals( + expectedEncryptionAlgorithm, + GetEncryptionAlgorithm(objectKey), + "When decrypting the EncryptionAlgorithm does not match the expected value: " + expectedEncryptionAlgorithm + ); + } catch (Exception e) { + failures.add(String.format( + "Failed to decrypt object '%s' (index %d): %s - %s", + crossLanguageObjects.get(i), i, e.getClass().getSimpleName(), e.getMessage() + )); + } + } + + if (!failures.isEmpty()) { + throw new AssertionError(String.format( + "Decryption failed for %d out of %d objects:\n%s", + failures.size(), crossLanguageObjects.size(), + String.join("\n", failures) + )); + } + } + + public static void Decrypt_fails( + S3ECTestServerClient client, + String S3ECId, List crossLanguageObjects, + EncryptionAlgorithm expectedEncryptionAlgorithm + ) { + + if (crossLanguageObjects.isEmpty()) { + throw new AssertionError("There is nothing to decrypt"); + } + + List successfulDecrypt = new ArrayList<>(); + for (String objectKey : crossLanguageObjects) { + try { + + assertEquals( + expectedEncryptionAlgorithm, + GetEncryptionAlgorithm(objectKey), + "Before decrypting the EncryptionAlgorithm does not match the expected value: " + expectedEncryptionAlgorithm + ); + GetObjectOutput output = client.getObject(GetObjectInput.builder() + .clientID(S3ECId) + .bucket(TestUtils.BUCKET) + .key(objectKey) + .build()); + // It should fail to decrypt + successfulDecrypt.add(objectKey); + } catch (S3EncryptionClientError e) { + // This is a success + // TODO, add the failure message + } + } + + assertEquals(successfulDecrypt.size(), 0, "Decryption should have failed:" + String.join(",", successfulDecrypt)); + } + + /** + * Perform ranged get operation with specified byte range + */ + public static void RangedGet( + S3ECTestServerClient client, + String S3ECId, + List objectKeys, + long rangeStart, + long rangeEnd, + EncryptionAlgorithm expectedEncryptionAlgorithm + ) { + + if (objectKeys.isEmpty()) { + throw new AssertionError("There is nothing to get"); + } + + List failures = new ArrayList<>(); + for (String objectKey : objectKeys) { + try { + // Get the full object first to know expected content + GetObjectOutput fullOutput = client.getObject(GetObjectInput.builder() + .clientID(S3ECId) + .bucket(TestUtils.BUCKET) + .key(objectKey) + .build()); + byte[] fullContent = fullOutput.getBody().array(); + + // Perform ranged get + GetObjectOutput output = client.getObject(GetObjectInput.builder() + .clientID(S3ECId) + .bucket(TestUtils.BUCKET) + .key(objectKey) + .range("bytes=" + rangeStart + "-" + rangeEnd) + .build()); + + // Verify the ranged content matches expected slice + byte[] rangedContent = output.getBody().array(); + int startIndex = (int) rangeStart; + int endIndex = (int) Math.min(rangeEnd + 1, fullContent.length); // +1 because HTTP ranges are inclusive + byte[] expectedContent = Arrays.copyOfRange(fullContent, startIndex, endIndex); + assertArrayEquals(expectedContent, rangedContent, + "Ranged get returned unexpected data for:" + objectKey); + + // Verify encryption algorithm + assertEquals( + expectedEncryptionAlgorithm, + GetEncryptionAlgorithm(objectKey), + "Encryption algorithm mismatch for " + objectKey + ); + } catch (Exception e) { + failures.add(String.format( + "Failed ranged get on '%s': %s - %s", + objectKey, e.getClass().getSimpleName(), e.getMessage() + )); + } + } + + if (!failures.isEmpty()) { + throw new AssertionError(String.format( + "Ranged get failed for %d out of %d objects:\n%s", + failures.size(), objectKeys.size(), + String.join("\n", failures) + )); + } + } + + /** + * Perform ranged get operations that are expected to fail + */ + public static void RangedGet_fails( + S3ECTestServerClient client, + String S3ECId, + List objectKeys, + long rangeStart, + long rangeEnd, + EncryptionAlgorithm expectedEncryptionAlgorithm + ) { + + if (objectKeys.isEmpty()) { + throw new AssertionError("There is nothing to get"); + } + + List successfulGets = new ArrayList<>(); + for (String objectKey : objectKeys) { + try { + assertEquals( + expectedEncryptionAlgorithm, + GetEncryptionAlgorithm(objectKey), + "Encryption algorithm mismatch for " + objectKey + ); + + GetObjectOutput output = client.getObject(GetObjectInput.builder() + .clientID(S3ECId) + .bucket(TestUtils.BUCKET) + .key(objectKey) + .range("bytes=" + rangeStart + "-" + rangeEnd) + .build()); + + // Should have failed but didn't + successfulGets.add(objectKey); + } catch (S3EncryptionClientError e) { + // This is expected - the ranged get should fail + } + } + + assertEquals(0, successfulGets.size(), + "Ranged get should have failed for: " + String.join(", ", successfulGets)); + } +} diff --git a/test-server/java-v3-transition-server/.duvet/.gitignore b/test-server/java-v3-transition-server/.duvet/.gitignore new file mode 100644 index 00000000..93956e36 --- /dev/null +++ b/test-server/java-v3-transition-server/.duvet/.gitignore @@ -0,0 +1,3 @@ +reports/ +requirements/ +specification/ \ No newline at end of file diff --git a/test-server/java-v3-transition-server/.duvet/config.toml b/test-server/java-v3-transition-server/.duvet/config.toml new file mode 100644 index 00000000..645410cf --- /dev/null +++ b/test-server/java-v3-transition-server/.duvet/config.toml @@ -0,0 +1,27 @@ +'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json" + +[[source]] +pattern = "s3ec-staging/**/*.java" + +# 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/java-v3-transition-server/.gitignore b/test-server/java-v3-transition-server/.gitignore new file mode 100644 index 00000000..e660fd93 --- /dev/null +++ b/test-server/java-v3-transition-server/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/test-server/java-v3-transition-server/Makefile b/test-server/java-v3-transition-server/Makefile new file mode 100644 index 00000000..81726b59 --- /dev/null +++ b/test-server/java-v3-transition-server/Makefile @@ -0,0 +1,42 @@ +# Makefile for S3 Encryption Client Testing + +.PHONY: build-server start-server stop-server wait-for-server + +PID_FILE := server.pid +PORT := 8094 + +build-server: + @echo "Building S3EC from source..." + cd s3ec-staging && mvn --batch-mode -no-transfer-progress clean compile && mvn -B -ntp install -DskipTests + @echo "S3EC build completed." + @echo "Building Java V3 Transition server..." + ./gradlew --build-cache --parallel --no-daemon build + +start-server: + @echo "Starting Java 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" \ + ./gradlew --build-cache --parallel run > server.log 2>&1 & echo $$! > $(PID_FILE) + @echo "Java 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/java-server/README.md b/test-server/java-v3-transition-server/README.md similarity index 63% rename from test-server/java-server/README.md rename to test-server/java-v3-transition-server/README.md index b2f5bb1b..5f08cc1c 100644 --- a/test-server/java-server/README.md +++ b/test-server/java-v3-transition-server/README.md @@ -1,6 +1,6 @@ -# S3EC Java Test Server +# S3EC Java V3 Test Server -This is the Java implementation of the S3ECTestServer framework. It provides a server implementation for testing S3 Encryption Client functionality. +This is the Java implementation of the S3ECTestServer framework for S3EC Java V3. It provides a server implementation for testing Java S3 Encryption Client V3 functionality. ## Overview @@ -18,6 +18,6 @@ To run the server: gradle run ``` -This will start the server running on port `8080`. +This will start the server running on port `8094`. 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/java-server/build.gradle.kts b/test-server/java-v3-transition-server/build.gradle.kts similarity index 77% rename from test-server/java-server/build.gradle.kts rename to test-server/java-v3-transition-server/build.gradle.kts index ca793e56..7f474fa0 100644 --- a/test-server/java-server/build.gradle.kts +++ b/test-server/java-v3-transition-server/build.gradle.kts @@ -4,6 +4,10 @@ plugins { application } +// Dynamically read S3EC version from submodule's pom.xml +val s3ecVersion = file("s3ec-staging/pom.xml").readText() + .let { Regex("(.*?)").find(it)?.groupValues?.get(1) ?: "3.6.0" } + dependencies { val smithyJavaVersion: String by project @@ -13,8 +17,9 @@ dependencies { implementation("software.amazon.smithy.java:server-netty:$smithyJavaVersion") implementation("software.amazon.smithy.java:aws-server-restjson:$smithyJavaVersion") - compileOnly("software.amazon.awssdk:aws-sdk-java:2.31.66") - implementation("software.amazon.encryption.s3:amazon-s3-encryption-client-java:3.3.5") + // S3EC from local Maven repository (installed by mvn install) + // Version is dynamically read from s3ec-staging/pom.xml + implementation("software.amazon.encryption.s3:amazon-s3-encryption-client-java:$s3ecVersion") } // Use that application plugin to start the service via the `run` task. diff --git a/test-server/java-v3-transition-server/gradle.properties b/test-server/java-v3-transition-server/gradle.properties new file mode 100644 index 00000000..483cd315 --- /dev/null +++ b/test-server/java-v3-transition-server/gradle.properties @@ -0,0 +1,24 @@ +# Smithy versions +smithyJavaVersion=[0,1] +smithyGradleVersion=1.1.0 +smithyVersion=[1,2] + +# Performance optimization settings + +# Force no-daemon mode - ensures Gradle doesn't try to keep a daemon alive +org.gradle.daemon=false + +# Set minimal idle timeout for any daemon-like behavior (1 second) +org.gradle.daemon.idletimeout=1000 + +# JVM arguments to prevent forking a separate JVM process +# By matching the JVM args here with what Gradle expects, we avoid the +# "single-use Daemon process will be forked" behavior +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC + +# Keep builds fast with parallel execution and caching +org.gradle.parallel=true +org.gradle.caching=true + +# Configure on demand to reduce startup time +org.gradle.configureondemand=true diff --git a/test-server/java-server/gradle/wrapper/gradle-wrapper.jar b/test-server/java-v3-transition-server/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from test-server/java-server/gradle/wrapper/gradle-wrapper.jar rename to test-server/java-v3-transition-server/gradle/wrapper/gradle-wrapper.jar diff --git a/test-server/java-server/gradle/wrapper/gradle-wrapper.properties b/test-server/java-v3-transition-server/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from test-server/java-server/gradle/wrapper/gradle-wrapper.properties rename to test-server/java-v3-transition-server/gradle/wrapper/gradle-wrapper.properties diff --git a/test-server/java-server/gradlew b/test-server/java-v3-transition-server/gradlew similarity index 100% rename from test-server/java-server/gradlew rename to test-server/java-v3-transition-server/gradlew diff --git a/test-server/java-server/gradlew.bat b/test-server/java-v3-transition-server/gradlew.bat similarity index 96% rename from test-server/java-server/gradlew.bat rename to test-server/java-v3-transition-server/gradlew.bat index 7101f8e4..25da30db 100644 --- a/test-server/java-server/gradlew.bat +++ b/test-server/java-v3-transition-server/gradlew.bat @@ -1,92 +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 +@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-server/license.txt b/test-server/java-v3-transition-server/license.txt similarity index 100% rename from test-server/java-server/license.txt rename to test-server/java-v3-transition-server/license.txt diff --git a/test-server/java-v3-transition-server/s3ec-staging b/test-server/java-v3-transition-server/s3ec-staging new file mode 160000 index 00000000..d829a235 --- /dev/null +++ b/test-server/java-v3-transition-server/s3ec-staging @@ -0,0 +1 @@ +Subproject commit d829a235854996e0f25736662510c2aa25e61fae diff --git a/test-server/java-server/settings.gradle.kts b/test-server/java-v3-transition-server/settings.gradle.kts similarity index 100% rename from test-server/java-server/settings.gradle.kts rename to test-server/java-v3-transition-server/settings.gradle.kts diff --git a/test-server/java-server/smithy-build.json b/test-server/java-v3-transition-server/smithy-build.json similarity index 100% rename from test-server/java-server/smithy-build.json rename to test-server/java-v3-transition-server/smithy-build.json diff --git a/test-server/java-v3-transition-server/specification b/test-server/java-v3-transition-server/specification new file mode 120000 index 00000000..b173f708 --- /dev/null +++ b/test-server/java-v3-transition-server/specification @@ -0,0 +1 @@ +../specification \ No newline at end of file diff --git a/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/CreateClientOperationImpl.java b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/CreateClientOperationImpl.java new file mode 100644 index 00000000..956f454b --- /dev/null +++ b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/CreateClientOperationImpl.java @@ -0,0 +1,198 @@ +package software.amazon.encryption.s3; + +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.retry.RetryPolicy; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.encryption.s3.internal.InstructionFileConfig; +import software.amazon.encryption.s3.algorithms.AlgorithmSuite; +import software.amazon.encryption.s3.materials.AesKeyring; +import software.amazon.encryption.s3.materials.Keyring; +import software.amazon.encryption.s3.materials.KmsKeyring; +import software.amazon.encryption.s3.materials.PartialRsaKeyPair; +import software.amazon.encryption.s3.materials.RsaKeyring; +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.GenericServerError; +import software.amazon.encryption.s3.model.KeyMaterial; +import software.amazon.encryption.s3.service.CreateClientOperation; +import software.amazon.smithy.java.server.RequestContext; + +import javax.crypto.spec.SecretKeySpec; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Map; +import java.util.UUID; + +import static software.amazon.encryption.s3.CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT; +import static software.amazon.encryption.s3.CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT; +import static software.amazon.encryption.s3.CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT; + +public class CreateClientOperationImpl implements CreateClientOperation { + private final Map clientCache_; + private final Map keyringCache_; + + public CreateClientOperationImpl(Map clientCache, Map keyringCache) { + clientCache_ = clientCache; + keyringCache_ = keyringCache; + } + + // Copied from S3EC. + private boolean onlyOneNonNull(Object... values) { + boolean haveOneNonNull = false; + for (Object o : values) { + if (o != null) { + if (haveOneNonNull) { + return false; + } + + haveOneNonNull = true; + } + } + + return haveOneNonNull; + } + + @Override + public CreateClientOutput createClient(CreateClientInput input, RequestContext context) { + try { + KeyMaterial key = input.getConfig().getKeyMaterial(); + if (!onlyOneNonNull(key.getAesKey(), key.getKmsKeyId(), key.getRsaKey())) { + throw new RuntimeException("KeyMaterial must be only one, non-null input!"); + } + Keyring keyring; + if (key.getAesKey() != null) { + byte[] keyBytes = new byte[key.getAesKey().remaining()]; + key.getAesKey().get(keyBytes); + keyring = AesKeyring.builder() + .wrappingKey(new SecretKeySpec(keyBytes, "AES")) + .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) + .build(); + } else if (key.getRsaKey() != null) { + try { + byte[] keyBytes = new byte[key.getRsaKey().remaining()]; + key.getRsaKey().get(keyBytes); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyFactory.generatePrivate(keySpec); + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec( + privateKey.getModulus(), + privateKey.getPublicExponent() + ); + + // Generate public key + PublicKey publicKey = keyFactory.generatePublic(publicKeySpec); + + keyring = RsaKeyring.builder() + .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) + .wrappingKeyPair(PartialRsaKeyPair.builder() + .publicKey(publicKey) + .privateKey(privateKey).build()) + .build(); + } catch (NoSuchAlgorithmException | InvalidKeySpecException nse) { + throw GenericServerError.builder() + .message(nse.getMessage()) + .build(); + } + } else if (key.getKmsKeyId() != null) { + keyring = KmsKeyring.builder() + .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) + .wrappingKeyId(key.getKmsKeyId()) + .build(); + } else { + throw new RuntimeException("No KeyMaterial found!"); + } + + // Configure S3 client with adaptive retry for throttling + RetryPolicy retryPolicy = RetryPolicy.builder() + .numRetries(5) + .throttlingBackoffStrategy(BackoffStrategy.defaultThrottlingStrategy()) + .build(); + + S3Client wrappedClient = S3Client.builder() + .overrideConfiguration(ClientOverrideConfiguration.builder() + .retryPolicy(retryPolicy) + .build()) + .build(); + + // V3 Transition server configuration + // Existing Builder defaults to FORBID_ENCRYPT and ALG_AES_256_GCM_IV12_TAG16_NO_KDF + S3EncryptionClient.Builder s3ClientBuilder = S3EncryptionClient.builder() + .wrappedClient(wrappedClient) + .keyring(keyring) + .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) + .enableLegacyUnauthenticatedModes(input.getConfig().isEnableLegacyUnauthenticatedModes()); + + // Instruction File Put Configuration + boolean instFilePut = false; + if (input.getConfig().getInstructionFileConfig() != null) { + instFilePut = input.getConfig().getInstructionFileConfig().isEnableInstructionFilePutObject(); + s3ClientBuilder.instructionFileConfig(InstructionFileConfig.builder() + .instructionFileClient(S3Client.create()) + .enableInstructionFilePutObject(instFilePut) + .build()); + } + + // Configure commitment policy if provided + if (input.getConfig().getCommitmentPolicy() != null) { + CommitmentPolicy policy = getCommitmentPolicy(input.getConfig().getCommitmentPolicy()); + s3ClientBuilder.commitmentPolicy(policy); + } + + // Configure encryption algorithm if provided + if (input.getConfig().getEncryptionAlgorithm() != null) { + AlgorithmSuite algorithm = getAlgorithmSuite(input.getConfig().getEncryptionAlgorithm()); + s3ClientBuilder.encryptionAlgorithm(algorithm); + } + + S3Client s3Client = s3ClientBuilder.build(); + + UUID uuid = UUID.randomUUID(); + String uuidString = uuid.toString(); + clientCache_.put(uuidString, s3Client); + keyringCache_.put(uuidString, keyring); + return CreateClientOutput.builder() + .clientId(uuidString) + .build(); + } catch (Exception e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw GenericServerError.builder() + .message(stackTrace) + .build(); + } + } + + private static AlgorithmSuite getAlgorithmSuite(EncryptionAlgorithm input) { + if (input.equals(EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF)) { + return AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF; + } else if (input.equals(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)) { + return AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF; + } else if (input.equals(EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY)) { + return AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY; + } else { + throw new RuntimeException("Unknown encryption algorithm: " + input); + } + } + + private static software.amazon.encryption.s3.CommitmentPolicy getCommitmentPolicy(software.amazon.encryption.s3.model.CommitmentPolicy input) { + if (input.equals(software.amazon.encryption.s3.model.CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)) { + return FORBID_ENCRYPT_ALLOW_DECRYPT; + } else if (input.equals(software.amazon.encryption.s3.model.CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT)) { + return REQUIRE_ENCRYPT_ALLOW_DECRYPT; + } else if (input.equals(software.amazon.encryption.s3.model.CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)) { + return REQUIRE_ENCRYPT_REQUIRE_DECRYPT; + } else { + throw new RuntimeException("Unknown commitment policy: " + input); + } + } +} diff --git a/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/GetObjectOperationImpl.java b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/GetObjectOperationImpl.java new file mode 100644 index 00000000..d3ab8289 --- /dev/null +++ b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/GetObjectOperationImpl.java @@ -0,0 +1,88 @@ +package software.amazon.encryption.s3; + +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.encryption.s3.S3EncryptionClientException; +import software.amazon.smithy.java.server.RequestContext; +import software.amazon.encryption.s3.model.GenericServerError; +import software.amazon.encryption.s3.model.GetObjectInput; +import software.amazon.encryption.s3.model.GetObjectOutput; +import software.amazon.encryption.s3.model.S3EncryptionClientError; +import software.amazon.encryption.s3.service.GetObjectOperation; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static software.amazon.encryption.s3.S3EncryptionClient.withAdditionalConfiguration; +import static software.amazon.encryption.s3.MetadataUtils.metadataListToMap; +import static software.amazon.encryption.s3.MetadataUtils.metadataMapToList; + +public class GetObjectOperationImpl implements GetObjectOperation { + private Map clientCache_; + + public GetObjectOperationImpl(Map clientCache) { + clientCache_ = clientCache; + } + + @Override + public GetObjectOutput getObject(GetObjectInput input, RequestContext context) { + try { + S3Client s3Client = clientCache_.get(input.getClientID()); + Map ecMap = metadataListToMap(input.getMetadata()); + + try { + ResponseBytes resp = s3Client.getObjectAsBytes(builder -> { + builder.bucket(input.getBucket()) + .key(input.getKey()); + + // Add custom instruction file suffix if provided + if (input.getInstructionFileSuffix() != null && !input.getInstructionFileSuffix().isEmpty()) { + builder.overrideConfiguration(config -> config + .putExecutionAttribute(S3EncryptionClient.CUSTOM_INSTRUCTION_FILE_SUFFIX, input.getInstructionFileSuffix()) + .applyMutation(c -> withAdditionalConfiguration(ecMap).accept(c))); + } else { + builder.overrideConfiguration(withAdditionalConfiguration(ecMap)); + } + + // Add range header if provided + if (input.getRange() != null && !input.getRange().isEmpty()) { + builder.range(input.getRange()); + } + }); + + List mdAsList = metadataMapToList(resp.response().metadata()); + // Can't use asBB else it gets mad bc cant access backing array + ByteBuffer bb = ByteBuffer.wrap(resp.asByteArray()); + GetObjectOutput output = GetObjectOutput.builder() + .body(bb) + .metadata(mdAsList) + .build(); + return output; + } catch (S3EncryptionClientException s3EncryptionClientException) { + // Modeled exceptions MUST be returned as such + StringWriter sw = new StringWriter(); + s3EncryptionClientException.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw S3EncryptionClientError.builder() + .message(stackTrace) + .build(); + } + } catch (Exception e) { + // Don't wrap modeled errors + if (e instanceof S3EncryptionClientError) { + throw e; + } + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw GenericServerError.builder() + .message(stackTrace) + .build(); + } + } +} diff --git a/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/MetadataUtils.java b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/MetadataUtils.java new file mode 100644 index 00000000..9eba6a3d --- /dev/null +++ b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/MetadataUtils.java @@ -0,0 +1,43 @@ +package software.amazon.encryption.s3; + +import software.amazon.encryption.s3.model.GenericServerError; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MetadataUtils { + + /** + * Annoyingly, Smithy doesn't provide an interface for map types + * in HTTP headers, so we have to do the serde ourselves + */ + public static List metadataMapToList(Map md) { + List mdAsList = new ArrayList<>(md.size()); + for (Map.Entry keyValue : md.entrySet()) { + mdAsList.add("[" + keyValue.getKey() + "]:[" + keyValue.getValue() + "]"); + } + return mdAsList; + } + + public static Map metadataListToMap(List mdList) { + Map md = new HashMap<>(); + for (String entry : mdList) { + // Split on "]:[" to separate key and value + String[] parts = entry.split("]:\\["); + if (parts.length == 2) { + // Remove remaining brackets from start and end + String key = parts[0].substring(1); + String value = parts[1].substring(0, parts[1].length() - 1); + md.put(key, value); + } else { + throw GenericServerError.builder() + .message("Malformed metadata list entry: " + entry) + .build(); + } + } + return md; + } + +} diff --git a/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/PutObjectOperationImpl.java b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/PutObjectOperationImpl.java new file mode 100644 index 00000000..ca76e83f --- /dev/null +++ b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/PutObjectOperationImpl.java @@ -0,0 +1,55 @@ +package software.amazon.encryption.s3; + +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.smithy.java.server.RequestContext; +import software.amazon.encryption.s3.model.GenericServerError; +import software.amazon.encryption.s3.model.PutObjectInput; +import software.amazon.encryption.s3.model.PutObjectOutput; +import software.amazon.encryption.s3.service.PutObjectOperation; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Map; +import java.util.stream.Collectors; + +import static software.amazon.encryption.s3.S3EncryptionClient.withAdditionalConfiguration; +import static software.amazon.encryption.s3.MetadataUtils.metadataListToMap; + +public class PutObjectOperationImpl implements PutObjectOperation { + + private Map clientCache_; + + public PutObjectOperationImpl(Map clientCache) { + clientCache_ = clientCache; + } + + @Override + public PutObjectOutput putObject(PutObjectInput input, RequestContext context) { + try { + final Map metadata = metadataListToMap(input.getMetadata()); + S3Client s3Client = clientCache_.get(input.getClientID()); + s3Client.putObject(builder -> builder + .bucket(input.getBucket()) + .key(input.getKey()) + .overrideConfiguration(withAdditionalConfiguration(metadata)), + RequestBody.fromByteBuffer(input.getBody()) + ); + // The real S3 doesn't provide bucket/key/metadata, so Test doesn't need to either, but we do anyway + return PutObjectOutput.builder() + .bucket(input.getBucket()) + .key(input.getKey()) + .metadata(input.getMetadata()) + .build(); + } catch (Exception e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw GenericServerError.builder() + .message(stackTrace) + .build(); + } + } +} diff --git a/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/ReEncryptOperationImpl.java b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/ReEncryptOperationImpl.java new file mode 100644 index 00000000..7a809761 --- /dev/null +++ b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/ReEncryptOperationImpl.java @@ -0,0 +1,183 @@ +package software.amazon.encryption.s3; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.encryption.s3.internal.ReEncryptInstructionFileRequest; +import software.amazon.encryption.s3.internal.ReEncryptInstructionFileResponse; +import software.amazon.encryption.s3.materials.AesKeyring; +import software.amazon.encryption.s3.materials.MaterialsDescription; +import software.amazon.encryption.s3.materials.PartialRsaKeyPair; +import software.amazon.encryption.s3.materials.RawKeyring; +import software.amazon.encryption.s3.materials.RsaKeyring; +import software.amazon.encryption.s3.model.GenericServerError; +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.S3EncryptionClientError; +import software.amazon.encryption.s3.service.ReEncryptOperation; +import software.amazon.smithy.java.server.RequestContext; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.util.Map; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +public class ReEncryptOperationImpl implements ReEncryptOperation { + private final Map clientCache_; + + public ReEncryptOperationImpl(Map clientCache) { + clientCache_ = clientCache; + } + + @Override + public ReEncryptOutput reEncrypt(ReEncryptInput input, RequestContext context) { + try { + S3Client s3Client = clientCache_.get(input.getClientID()); + + // Ensure we have an S3EncryptionClient, not just a plain S3Client + if (!(s3Client instanceof S3EncryptionClient)) { + throw new IllegalStateException( + "Client " + input.getClientID() + " is not an S3EncryptionClient"); + } + + S3EncryptionClient s3EncryptionClient = (S3EncryptionClient) s3Client; + + // Create a new keyring from the provided newKeyMaterial + KeyMaterial newKeyMaterial = input.getNewKeyMaterial(); + if (newKeyMaterial == null) { + throw new IllegalStateException( + "newKeyMaterial is required for ReEncrypt operation"); + } + + RawKeyring newKeyring = createKeyringFromMaterial(newKeyMaterial); + + try { + // Build the ReEncryptInstructionFileRequest + ReEncryptInstructionFileRequest.Builder requestBuilder = + ReEncryptInstructionFileRequest.builder() + .bucket(input.getBucket()) + .key(input.getKey()) + .newKeyring(newKeyring); + + // Add optional instruction file suffix if provided + if (input.getInstructionFileSuffix() != null && !input.getInstructionFileSuffix().isEmpty()) { + requestBuilder.instructionFileSuffix(input.getInstructionFileSuffix()); + } + + // Add optional enforceRotation if provided + if (input.isEnforceRotation() != null) { + requestBuilder.enforceRotation(input.isEnforceRotation()); + } + + ReEncryptInstructionFileRequest reEncryptRequest = requestBuilder.build(); + + // Perform the re-encryption + ReEncryptInstructionFileResponse response = + s3EncryptionClient.reEncryptInstructionFile(reEncryptRequest); + + // Build and return the output + return ReEncryptOutput.builder() + .bucket(response.bucket()) + .key(response.key()) + .instructionFileSuffix(response.instructionFileSuffix()) + .enforceRotation(response.enforceRotation()) + .build(); + + } catch (S3EncryptionClientException s3EncryptionClientException) { + // Modeled exceptions MUST be returned as such + StringWriter sw = new StringWriter(); + s3EncryptionClientException.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw S3EncryptionClientError.builder() + .message(stackTrace) + .build(); + } + } catch (Exception e) { + // Don't wrap modeled errors + if (e instanceof S3EncryptionClientError) { + throw e; + } + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw GenericServerError.builder() + .message(stackTrace) + .build(); + } + } + + /** + * Creates a RawKeyring from KeyMaterial. + * The KeyMaterial should have exactly one of: aesKey, rsaKey, or kmsKeyId set. + */ + private RawKeyring createKeyringFromMaterial(KeyMaterial keyMaterial) { + try { + // Get materials description from KeyMaterial if provided + MaterialsDescription materialsDescription = null; + if (keyMaterial.getMaterialsDescription() != null && !keyMaterial.getMaterialsDescription().isEmpty()) { + MaterialsDescription.Builder builder = MaterialsDescription.builder(); + for (Map.Entry entry : keyMaterial.getMaterialsDescription().entrySet()) { + builder.put(entry.getKey(), entry.getValue()); + } + materialsDescription = builder.build(); + } + + // Check for AES key + if (keyMaterial.getAesKey() != null) { + byte[] aesKeyBytes = new byte[keyMaterial.getAesKey().remaining()]; + keyMaterial.getAesKey().get(aesKeyBytes); + SecretKey secretKey = new SecretKeySpec(aesKeyBytes, "AES"); + + AesKeyring.Builder keyringBuilder = AesKeyring.builder() + .wrappingKey(secretKey); + + if (materialsDescription != null) { + keyringBuilder.materialsDescription(materialsDescription); + } + + return keyringBuilder.build(); + } + + // Check for RSA key + if (keyMaterial.getRsaKey() != null) { + byte[] rsaKeyBytes = new byte[keyMaterial.getRsaKey().remaining()]; + keyMaterial.getRsaKey().get(rsaKeyBytes); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(rsaKeyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyFactory.generatePrivate(keySpec); + + // Derive the public key from the private key + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec( + privateKey.getModulus(), + privateKey.getPublicExponent() + ); + PublicKey publicKey = keyFactory.generatePublic(publicKeySpec); + + PartialRsaKeyPair keyPair = PartialRsaKeyPair.builder() + .privateKey(privateKey) + .publicKey(publicKey) + .build(); + + RsaKeyring.Builder keyringBuilder = RsaKeyring.builder() + .wrappingKeyPair(keyPair); + + if (materialsDescription != null) { + keyringBuilder.materialsDescription(materialsDescription); + } + + return keyringBuilder.build(); + } + + throw new IllegalStateException( + "KeyMaterial must have either aesKey or rsaKey set"); + } catch (Exception e) { + throw new IllegalStateException("Failed to create keyring from KeyMaterial: " + e.getMessage(), e); + } + } +} diff --git a/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/S3ECJavaTestServer.java b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/S3ECJavaTestServer.java new file mode 100644 index 00000000..78c84dff --- /dev/null +++ b/test-server/java-v3-transition-server/src/main/java/software/amazon/encryption/s3/S3ECJavaTestServer.java @@ -0,0 +1,56 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.encryption.s3; + +import software.amazon.awssdk.services.s3.S3Client; + +import java.net.URI; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; + +import software.amazon.smithy.java.server.Server; +import software.amazon.encryption.s3.service.S3ECTestServer; + +public class S3ECJavaTestServer implements Runnable { + static final URI endpoint = URI.create("http://localhost:8094"); + + public static void main(String[] args) { + new S3ECJavaTestServer().run(); + } + + @Override + public void run() { + // All the S3EC instances live here. + // Obviously this can get messy in a real service. + // Assume that the tests behave and don't induce weird race conditions. + Map clientCache = new ConcurrentHashMap<>(); + Map keyringCache = new ConcurrentHashMap<>(); + + Server server = Server.builder() + .endpoints(endpoint) + .addService( + S3ECTestServer.builder() + .addCreateClientOperation(new CreateClientOperationImpl(clientCache, keyringCache)) + .addGetObjectOperation(new GetObjectOperationImpl(clientCache)) + .addPutObjectOperation(new PutObjectOperationImpl(clientCache)) + .addReEncryptOperation(new ReEncryptOperationImpl(clientCache)) + .build()) + .build(); + System.out.println("Starting server..."); + server.start(); + try { + Thread.currentThread().join(); + } catch (InterruptedException e) { + System.out.println("Stopping server..."); + try { + server.shutdown().get(); + } catch (InterruptedException | ExecutionException ex) { + throw new RuntimeException(ex); + } + } + } +} diff --git a/test-server/java-v4-server/.duvet/.gitignore b/test-server/java-v4-server/.duvet/.gitignore new file mode 100644 index 00000000..93956e36 --- /dev/null +++ b/test-server/java-v4-server/.duvet/.gitignore @@ -0,0 +1,3 @@ +reports/ +requirements/ +specification/ \ No newline at end of file diff --git a/test-server/java-v4-server/.duvet/config.toml b/test-server/java-v4-server/.duvet/config.toml new file mode 100644 index 00000000..645410cf --- /dev/null +++ b/test-server/java-v4-server/.duvet/config.toml @@ -0,0 +1,27 @@ +'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json" + +[[source]] +pattern = "s3ec-staging/**/*.java" + +# 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/java-v4-server/.gitignore b/test-server/java-v4-server/.gitignore new file mode 100644 index 00000000..e660fd93 --- /dev/null +++ b/test-server/java-v4-server/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/test-server/java-v4-server/Makefile b/test-server/java-v4-server/Makefile new file mode 100644 index 00000000..3d1aae2a --- /dev/null +++ b/test-server/java-v4-server/Makefile @@ -0,0 +1,42 @@ +# Makefile for S3 Encryption Client Testing + +.PHONY: build-server start-server stop-server wait-for-server + +PID_FILE := server.pid +PORT := 8088 + +build-server: + @echo "Building S3EC from source..." + cd s3ec-staging && mvn --batch-mode -no-transfer-progress clean compile && mvn -B -ntp install -DskipTests + @echo "S3EC build completed." + @echo "Building Java V4 server..." + ./gradlew --build-cache --parallel --no-daemon build + +start-server: + @echo "Starting Java 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" \ + ./gradlew --build-cache --parallel run > server.log 2>&1 & echo $$! > $(PID_FILE) + @echo "Java 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/java-v4-server/README.md b/test-server/java-v4-server/README.md new file mode 100644 index 00000000..70d60914 --- /dev/null +++ b/test-server/java-v4-server/README.md @@ -0,0 +1,23 @@ +# S3EC Java V4 (Improved) Test Server + +This is the Java implementation of the S3ECTestServer framework for S3EC Java V4 (Improved). It provides a server implementation for testing Java S3 Encryption Client V4 (Improved) functionality. + +## Overview + +The S3ECJavaTestServer 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 +gradle run +``` + +This will start the server running on port `8088`. + +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/java-v4-server/build.gradle.kts b/test-server/java-v4-server/build.gradle.kts new file mode 100644 index 00000000..d55d93d7 --- /dev/null +++ b/test-server/java-v4-server/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + `java-library` + id("software.amazon.smithy.gradle.smithy-base") + application +} + +// Dynamically read S3EC version from submodule's pom.xml +val s3ecVersion = file("s3ec-staging/pom.xml").readText() + .let { Regex("(.*?)").find(it)?.groupValues?.get(1) ?: "4.0.0" } + +dependencies { + val smithyJavaVersion: String by project + + smithyBuild("software.amazon.smithy.java:plugins:$smithyJavaVersion") + + implementation("software.amazon.smithy:smithy-rules-engine:1.59.0") + implementation("software.amazon.smithy.java:server-netty:$smithyJavaVersion") + implementation("software.amazon.smithy.java:aws-server-restjson:$smithyJavaVersion") + + // S3EC from local Maven repository (installed by mvn install) + // Version is dynamically read from s3ec-staging/pom.xml + implementation("software.amazon.encryption.s3:amazon-s3-encryption-client-java:$s3ecVersion") +} + +// Use that application plugin to start the service via the `run` task. +application { + mainClass = "software.amazon.encryption.s3.S3ECJavaTestServer" +} + +// Add generated Java files to the main sourceSet +afterEvaluate { + val serverPath = smithy.getPluginProjectionPath(smithy.sourceProjection.get(), "java-server-codegen") + sourceSets { + main { + java { + srcDir(serverPath) + } + } + } +} + +tasks { + compileJava { + dependsOn(smithyBuild) + } +} + +// Helps Intellij IDE's discover smithy models +sourceSets { + main { + java { + srcDir("../model") + } + } +} + +repositories { + mavenLocal() + mavenCentral() +} diff --git a/test-server/java-v4-server/gradle.properties b/test-server/java-v4-server/gradle.properties new file mode 100644 index 00000000..483cd315 --- /dev/null +++ b/test-server/java-v4-server/gradle.properties @@ -0,0 +1,24 @@ +# Smithy versions +smithyJavaVersion=[0,1] +smithyGradleVersion=1.1.0 +smithyVersion=[1,2] + +# Performance optimization settings + +# Force no-daemon mode - ensures Gradle doesn't try to keep a daemon alive +org.gradle.daemon=false + +# Set minimal idle timeout for any daemon-like behavior (1 second) +org.gradle.daemon.idletimeout=1000 + +# JVM arguments to prevent forking a separate JVM process +# By matching the JVM args here with what Gradle expects, we avoid the +# "single-use Daemon process will be forked" behavior +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC + +# Keep builds fast with parallel execution and caching +org.gradle.parallel=true +org.gradle.caching=true + +# Configure on demand to reduce startup time +org.gradle.configureondemand=true diff --git a/test-server/java-v4-server/gradle/wrapper/gradle-wrapper.jar b/test-server/java-v4-server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..e6441136 Binary files /dev/null and b/test-server/java-v4-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/test-server/java-v4-server/gradle/wrapper/gradle-wrapper.properties b/test-server/java-v4-server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..a4413138 --- /dev/null +++ b/test-server/java-v4-server/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-v4-server/gradlew b/test-server/java-v4-server/gradlew new file mode 100755 index 00000000..b740cf13 --- /dev/null +++ b/test-server/java-v4-server/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-v4-server/gradlew.bat b/test-server/java-v4-server/gradlew.bat new file mode 100644 index 00000000..25da30db --- /dev/null +++ b/test-server/java-v4-server/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-v4-server/license.txt b/test-server/java-v4-server/license.txt new file mode 100644 index 00000000..2dd564b3 --- /dev/null +++ b/test-server/java-v4-server/license.txt @@ -0,0 +1,4 @@ +/* + * Example file license header. + * File header line two + */ \ No newline at end of file diff --git a/test-server/java-v4-server/s3ec-staging b/test-server/java-v4-server/s3ec-staging new file mode 160000 index 00000000..a95aa3fd --- /dev/null +++ b/test-server/java-v4-server/s3ec-staging @@ -0,0 +1 @@ +Subproject commit a95aa3fddb5abf4e17551c0ef3c247c7a43edf40 diff --git a/test-server/java-v4-server/settings.gradle.kts b/test-server/java-v4-server/settings.gradle.kts new file mode 100644 index 00000000..e7c41714 --- /dev/null +++ b/test-server/java-v4-server/settings.gradle.kts @@ -0,0 +1,19 @@ +/** + * Basic usage of generated server stubs. + */ + +pluginManagement { + val smithyGradleVersion: String by settings + + plugins { + id("software.amazon.smithy.gradle.smithy-base").version(smithyGradleVersion) + } + + repositories { + mavenLocal() + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "S3ECJavaTestServer" diff --git a/test-server/java-v4-server/smithy-build.json b/test-server/java-v4-server/smithy-build.json new file mode 100644 index 00000000..a0fcb8e5 --- /dev/null +++ b/test-server/java-v4-server/smithy-build.json @@ -0,0 +1,11 @@ +{ + "version": "2.0", + "plugins": { + "java-server-codegen": { + "service": "software.amazon.encryption.s3#S3ECTestServer", + "namespace": "software.amazon.encryption.s3", + "headerFile": "license.txt" + } + }, + "sources": ["../model"] +} diff --git a/test-server/java-v4-server/specification b/test-server/java-v4-server/specification new file mode 120000 index 00000000..b173f708 --- /dev/null +++ b/test-server/java-v4-server/specification @@ -0,0 +1 @@ +../specification \ No newline at end of file diff --git a/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/CreateClientOperationImpl.java b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/CreateClientOperationImpl.java new file mode 100644 index 00000000..23f3a11d --- /dev/null +++ b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/CreateClientOperationImpl.java @@ -0,0 +1,219 @@ +package software.amazon.encryption.s3; + +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration; +import software.amazon.awssdk.core.retry.RetryPolicy; +import software.amazon.awssdk.core.retry.backoff.BackoffStrategy; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.encryption.s3.internal.InstructionFileConfig; +import software.amazon.encryption.s3.algorithms.AlgorithmSuite; +import software.amazon.encryption.s3.materials.AesKeyring; +import software.amazon.encryption.s3.materials.Keyring; +import software.amazon.encryption.s3.materials.KmsKeyring; +import software.amazon.encryption.s3.materials.MaterialsDescription; +import software.amazon.encryption.s3.materials.PartialRsaKeyPair; +import software.amazon.encryption.s3.materials.RsaKeyring; +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.GenericServerError; +import software.amazon.encryption.s3.model.KeyMaterial; +import software.amazon.encryption.s3.service.CreateClientOperation; +import software.amazon.smithy.java.server.RequestContext; + +import javax.crypto.spec.SecretKeySpec; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Map; +import java.util.UUID; + +import static software.amazon.encryption.s3.CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT; +import static software.amazon.encryption.s3.CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT; +import static software.amazon.encryption.s3.CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT; + +public class CreateClientOperationImpl implements CreateClientOperation { + private final Map clientCache_; + private final Map keyringCache_; + + public CreateClientOperationImpl(Map clientCache, Map keyringCache) { + clientCache_ = clientCache; + keyringCache_ = keyringCache; + } + + // Copied from S3EC. + private boolean onlyOneNonNull(Object... values) { + boolean haveOneNonNull = false; + for (Object o : values) { + if (o != null) { + if (haveOneNonNull) { + return false; + } + + haveOneNonNull = true; + } + } + + return haveOneNonNull; + } + + @Override + public CreateClientOutput createClient(CreateClientInput input, RequestContext context) { + try { + KeyMaterial key = input.getConfig().getKeyMaterial(); + if (!onlyOneNonNull(key.getAesKey(), key.getKmsKeyId(), key.getRsaKey())) { + throw new RuntimeException("KeyMaterial must be only one, non-null input!"); + } + Keyring keyring; + if (key.getAesKey() != null) { + byte[] keyBytes = new byte[key.getAesKey().remaining()]; + key.getAesKey().get(keyBytes); + + AesKeyring.Builder aesBuilder = AesKeyring.builder() + .wrappingKey(new SecretKeySpec(keyBytes, "AES")) + .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()); + + // Add materials description if provided + if (key.getMaterialsDescription() != null && !key.getMaterialsDescription().isEmpty()) { + MaterialsDescription.Builder matDescBuilder = MaterialsDescription.builder(); + for (Map.Entry entry : key.getMaterialsDescription().entrySet()) { + matDescBuilder.put(entry.getKey(), entry.getValue()); + } + aesBuilder.materialsDescription(matDescBuilder.build()); + } + + keyring = aesBuilder.build(); + } else if (key.getRsaKey() != null) { + try { + byte[] keyBytes = new byte[key.getRsaKey().remaining()]; + key.getRsaKey().get(keyBytes); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyFactory.generatePrivate(keySpec); + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec( + privateKey.getModulus(), + privateKey.getPublicExponent() + ); + + // Generate public key + PublicKey publicKey = keyFactory.generatePublic(publicKeySpec); + + RsaKeyring.Builder rsaBuilder = RsaKeyring.builder() + .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) + .wrappingKeyPair(PartialRsaKeyPair.builder() + .publicKey(publicKey) + .privateKey(privateKey).build()); + + // Add materials description if provided + if (key.getMaterialsDescription() != null && !key.getMaterialsDescription().isEmpty()) { + MaterialsDescription.Builder matDescBuilder = MaterialsDescription.builder(); + for (Map.Entry entry : key.getMaterialsDescription().entrySet()) { + matDescBuilder.put(entry.getKey(), entry.getValue()); + } + rsaBuilder.materialsDescription(matDescBuilder.build()); + } + + keyring = rsaBuilder.build(); + } catch (NoSuchAlgorithmException | InvalidKeySpecException nse) { + throw GenericServerError.builder() + .message(nse.getMessage()) + .build(); + } + } else if (key.getKmsKeyId() != null) { + keyring = KmsKeyring.builder() + .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) + .wrappingKeyId(key.getKmsKeyId()) + .build(); + } else { + throw new RuntimeException("No KeyMaterial found!"); + } + + // Configure S3 client with adaptive retry for throttling + RetryPolicy retryPolicy = RetryPolicy.builder() + .numRetries(5) + .throttlingBackoffStrategy(BackoffStrategy.defaultThrottlingStrategy()) + .build(); + + S3Client wrappedClient = S3Client.builder() + .overrideConfiguration(ClientOverrideConfiguration.builder() + .retryPolicy(retryPolicy) + .build()) + .build(); + + // V4-Improved server configuration + S3EncryptionClient.Builder s3ClientBuilder = S3EncryptionClient.builderV4() + .wrappedClient(wrappedClient) + .keyring(keyring) + .enableLegacyWrappingAlgorithms(input.getConfig().isEnableLegacyWrappingAlgorithms()) + .enableLegacyUnauthenticatedModes(input.getConfig().isEnableLegacyUnauthenticatedModes()); + + // Client Creation + boolean instFilePut = false; + if (input.getConfig().getInstructionFileConfig() != null) { + instFilePut = input.getConfig().getInstructionFileConfig().isEnableInstructionFilePutObject(); + s3ClientBuilder.instructionFileConfig(InstructionFileConfig.builder() + .instructionFileClient(S3Client.create()) + .enableInstructionFilePutObject(instFilePut) + .build()); + } + + // Configure commitment policy if provided + if (input.getConfig().getCommitmentPolicy() != null) { + CommitmentPolicy policy = getCommitmentPolicy(input.getConfig().getCommitmentPolicy()); + s3ClientBuilder.commitmentPolicy(policy); + } + + // Configure encryption algorithm if provided + if (input.getConfig().getEncryptionAlgorithm() != null) { + AlgorithmSuite algorithm = getAlgorithmSuite(input.getConfig().getEncryptionAlgorithm()); + s3ClientBuilder.encryptionAlgorithm(algorithm); + } + + S3Client s3Client = s3ClientBuilder.build(); + + UUID uuid = UUID.randomUUID(); + String uuidString = uuid.toString(); + clientCache_.put(uuidString, s3Client); + keyringCache_.put(uuidString, keyring); + return CreateClientOutput.builder() + .clientId(uuidString) + .build(); + } catch (Exception e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw GenericServerError.builder() + .message(stackTrace) + .build(); + } + } + + private static AlgorithmSuite getAlgorithmSuite(EncryptionAlgorithm input) { + if (input.equals(EncryptionAlgorithm.ALG_AES_256_CBC_IV16_NO_KDF)) { + return AlgorithmSuite.ALG_AES_256_CBC_IV16_NO_KDF; + } else if (input.equals(EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF)) { + return AlgorithmSuite.ALG_AES_256_GCM_IV12_TAG16_NO_KDF; + } else if (input.equals(EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY)) { + return AlgorithmSuite.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY; + } else { + throw new RuntimeException("Unknown encryption algorithm: " + input); + } + } + + private static software.amazon.encryption.s3.CommitmentPolicy getCommitmentPolicy(software.amazon.encryption.s3.model.CommitmentPolicy input) { + if (input.equals(software.amazon.encryption.s3.model.CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)) { + return FORBID_ENCRYPT_ALLOW_DECRYPT; + } else if (input.equals(software.amazon.encryption.s3.model.CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT)) { + return REQUIRE_ENCRYPT_ALLOW_DECRYPT; + } else if (input.equals(software.amazon.encryption.s3.model.CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT)) { + return REQUIRE_ENCRYPT_REQUIRE_DECRYPT; + } else { + throw new RuntimeException("Unknown commitment policy: " + input); + } + } +} diff --git a/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/GetObjectOperationImpl.java b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/GetObjectOperationImpl.java new file mode 100644 index 00000000..a1964085 --- /dev/null +++ b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/GetObjectOperationImpl.java @@ -0,0 +1,86 @@ +package software.amazon.encryption.s3; + +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.encryption.s3.model.GenericServerError; +import software.amazon.encryption.s3.model.GetObjectInput; +import software.amazon.encryption.s3.model.GetObjectOutput; +import software.amazon.encryption.s3.model.S3EncryptionClientError; +import software.amazon.encryption.s3.service.GetObjectOperation; +import software.amazon.smithy.java.server.RequestContext; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; + +import static software.amazon.encryption.s3.MetadataUtils.metadataListToMap; +import static software.amazon.encryption.s3.MetadataUtils.metadataMapToList; +import static software.amazon.encryption.s3.S3EncryptionClient.withAdditionalConfiguration; + +public class GetObjectOperationImpl implements GetObjectOperation { + private final Map clientCache_; + + public GetObjectOperationImpl(Map clientCache) { + clientCache_ = clientCache; + } + + @Override + public GetObjectOutput getObject(GetObjectInput input, RequestContext context) { + try { + S3Client s3Client = clientCache_.get(input.getClientID()); + Map ecMap = metadataListToMap(input.getMetadata()); + + try { + ResponseBytes resp = s3Client.getObjectAsBytes(builder -> { + builder.bucket(input.getBucket()) + .key(input.getKey()); + + // Add custom instruction file suffix if provided + if (input.getInstructionFileSuffix() != null && !input.getInstructionFileSuffix().isEmpty()) { + builder.overrideConfiguration(config -> config + .putExecutionAttribute(S3EncryptionClient.CUSTOM_INSTRUCTION_FILE_SUFFIX, input.getInstructionFileSuffix()) + .applyMutation(c -> withAdditionalConfiguration(ecMap).accept(c))); + } else { + builder.overrideConfiguration(withAdditionalConfiguration(ecMap)); + } + + // Add range header if provided + if (input.getRange() != null && !input.getRange().isEmpty()) { + builder.range(input.getRange()); + } + }); + + List mdAsList = metadataMapToList(resp.response().metadata()); + // Can't use asBB else it gets mad bc cant access backing array + ByteBuffer bb = ByteBuffer.wrap(resp.asByteArray()); + GetObjectOutput output = GetObjectOutput.builder() + .body(bb) + .metadata(mdAsList) + .build(); + return output; + } catch (S3EncryptionClientException s3EncryptionClientException) { + // Modeled exceptions MUST be returned as such + StringWriter sw = new StringWriter(); + s3EncryptionClientException.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw S3EncryptionClientError.builder() + .message(stackTrace) + .build(); + } + } catch (Exception e) { + // Don't wrap modeled errors + if (e instanceof S3EncryptionClientError) { + throw e; + } + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw GenericServerError.builder() + .message(stackTrace) + .build(); + } + } +} diff --git a/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/MetadataUtils.java b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/MetadataUtils.java new file mode 100644 index 00000000..9eba6a3d --- /dev/null +++ b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/MetadataUtils.java @@ -0,0 +1,43 @@ +package software.amazon.encryption.s3; + +import software.amazon.encryption.s3.model.GenericServerError; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class MetadataUtils { + + /** + * Annoyingly, Smithy doesn't provide an interface for map types + * in HTTP headers, so we have to do the serde ourselves + */ + public static List metadataMapToList(Map md) { + List mdAsList = new ArrayList<>(md.size()); + for (Map.Entry keyValue : md.entrySet()) { + mdAsList.add("[" + keyValue.getKey() + "]:[" + keyValue.getValue() + "]"); + } + return mdAsList; + } + + public static Map metadataListToMap(List mdList) { + Map md = new HashMap<>(); + for (String entry : mdList) { + // Split on "]:[" to separate key and value + String[] parts = entry.split("]:\\["); + if (parts.length == 2) { + // Remove remaining brackets from start and end + String key = parts[0].substring(1); + String value = parts[1].substring(0, parts[1].length() - 1); + md.put(key, value); + } else { + throw GenericServerError.builder() + .message("Malformed metadata list entry: " + entry) + .build(); + } + } + return md; + } + +} diff --git a/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/PutObjectOperationImpl.java b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/PutObjectOperationImpl.java new file mode 100644 index 00000000..d399f13d --- /dev/null +++ b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/PutObjectOperationImpl.java @@ -0,0 +1,52 @@ +package software.amazon.encryption.s3; + +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.encryption.s3.model.GenericServerError; +import software.amazon.encryption.s3.model.PutObjectInput; +import software.amazon.encryption.s3.model.PutObjectOutput; +import software.amazon.encryption.s3.service.PutObjectOperation; +import software.amazon.smithy.java.server.RequestContext; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Map; + +import static software.amazon.encryption.s3.MetadataUtils.metadataListToMap; +import static software.amazon.encryption.s3.S3EncryptionClient.withAdditionalConfiguration; + +public class PutObjectOperationImpl implements PutObjectOperation { + + private final Map clientCache_; + + public PutObjectOperationImpl(Map clientCache) { + clientCache_ = clientCache; + } + + @Override + public PutObjectOutput putObject(PutObjectInput input, RequestContext context) { + try { + final Map metadata = metadataListToMap(input.getMetadata()); + S3Client s3Client = clientCache_.get(input.getClientID()); + s3Client.putObject(builder -> builder + .bucket(input.getBucket()) + .key(input.getKey()) + .overrideConfiguration(withAdditionalConfiguration(metadata)), + RequestBody.fromByteBuffer(input.getBody()) + ); + // The real S3 doesn't provide bucket/key/metadata, so Test doesn't need to either, but we do anyway + return PutObjectOutput.builder() + .bucket(input.getBucket()) + .key(input.getKey()) + .metadata(input.getMetadata()) + .build(); + } catch (Exception e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw GenericServerError.builder() + .message(stackTrace) + .build(); + } + } +} diff --git a/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/ReEncryptOperationImpl.java b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/ReEncryptOperationImpl.java new file mode 100644 index 00000000..6a7cd5b6 --- /dev/null +++ b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/ReEncryptOperationImpl.java @@ -0,0 +1,183 @@ +package software.amazon.encryption.s3; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.encryption.s3.internal.ReEncryptInstructionFileRequest; +import software.amazon.encryption.s3.internal.ReEncryptInstructionFileResponse; +import software.amazon.encryption.s3.materials.AesKeyring; +import software.amazon.encryption.s3.materials.MaterialsDescription; +import software.amazon.encryption.s3.materials.PartialRsaKeyPair; +import software.amazon.encryption.s3.materials.RawKeyring; +import software.amazon.encryption.s3.materials.RsaKeyring; +import software.amazon.encryption.s3.model.GenericServerError; +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.S3EncryptionClientError; +import software.amazon.encryption.s3.service.ReEncryptOperation; +import software.amazon.smithy.java.server.RequestContext; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.interfaces.RSAPrivateCrtKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.util.HashMap; +import java.util.Map; + +public class ReEncryptOperationImpl implements ReEncryptOperation { + private final Map clientCache_; + + public ReEncryptOperationImpl(Map clientCache) { + clientCache_ = clientCache; + } + + @Override + public ReEncryptOutput reEncrypt(ReEncryptInput input, RequestContext context) { + try { + S3Client s3Client = clientCache_.get(input.getClientID()); + + // Ensure we have an S3EncryptionClient, not just a plain S3Client + if (!(s3Client instanceof S3EncryptionClient)) { + throw new IllegalStateException( + "Client " + input.getClientID() + " is not an S3EncryptionClient"); + } + + S3EncryptionClient s3EncryptionClient = (S3EncryptionClient) s3Client; + + // Create a new keyring from the provided newKeyMaterial + KeyMaterial newKeyMaterial = input.getNewKeyMaterial(); + if (newKeyMaterial == null) { + throw new IllegalStateException( + "newKeyMaterial is required for ReEncrypt operation"); + } + + RawKeyring newKeyring = createKeyringFromMaterial(newKeyMaterial); + + try { + // Build the ReEncryptInstructionFileRequest + ReEncryptInstructionFileRequest.Builder requestBuilder = + ReEncryptInstructionFileRequest.builder() + .bucket(input.getBucket()) + .key(input.getKey()) + .newKeyring(newKeyring); + + // Add optional instruction file suffix if provided + if (input.getInstructionFileSuffix() != null && !input.getInstructionFileSuffix().isEmpty()) { + requestBuilder.instructionFileSuffix(input.getInstructionFileSuffix()); + } + + // Add optional enforceRotation if provided + if (input.isEnforceRotation() != null) { + requestBuilder.enforceRotation(input.isEnforceRotation()); + } + + ReEncryptInstructionFileRequest reEncryptRequest = requestBuilder.build(); + + // Perform the re-encryption + ReEncryptInstructionFileResponse response = + s3EncryptionClient.reEncryptInstructionFile(reEncryptRequest); + + // Build and return the output + return ReEncryptOutput.builder() + .bucket(response.bucket()) + .key(response.key()) + .instructionFileSuffix(response.instructionFileSuffix()) + .enforceRotation(response.enforceRotation()) + .build(); + + } catch (S3EncryptionClientException s3EncryptionClientException) { + // Modeled exceptions MUST be returned as such + StringWriter sw = new StringWriter(); + s3EncryptionClientException.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw S3EncryptionClientError.builder() + .message(stackTrace) + .build(); + } + } catch (Exception e) { + // Don't wrap modeled errors + if (e instanceof S3EncryptionClientError) { + throw e; + } + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + String stackTrace = sw.toString(); + throw GenericServerError.builder() + .message(stackTrace) + .build(); + } + } + + /** + * Creates a RawKeyring from KeyMaterial. + * The KeyMaterial should have exactly one of: aesKey, rsaKey, or kmsKeyId set. + */ + private RawKeyring createKeyringFromMaterial(KeyMaterial keyMaterial) { + try { + // Get materials description from KeyMaterial if provided + MaterialsDescription materialsDescription = null; + if (keyMaterial.getMaterialsDescription() != null && !keyMaterial.getMaterialsDescription().isEmpty()) { + MaterialsDescription.Builder builder = MaterialsDescription.builder(); + for (Map.Entry entry : keyMaterial.getMaterialsDescription().entrySet()) { + builder.put(entry.getKey(), entry.getValue()); + } + materialsDescription = builder.build(); + } + + // Check for AES key + if (keyMaterial.getAesKey() != null) { + byte[] aesKeyBytes = new byte[keyMaterial.getAesKey().remaining()]; + keyMaterial.getAesKey().get(aesKeyBytes); + SecretKey secretKey = new SecretKeySpec(aesKeyBytes, "AES"); + + AesKeyring.Builder keyringBuilder = AesKeyring.builder() + .wrappingKey(secretKey); + + if (materialsDescription != null) { + keyringBuilder.materialsDescription(materialsDescription); + } + + return keyringBuilder.build(); + } + + // Check for RSA key + if (keyMaterial.getRsaKey() != null) { + byte[] rsaKeyBytes = new byte[keyMaterial.getRsaKey().remaining()]; + keyMaterial.getRsaKey().get(rsaKeyBytes); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(rsaKeyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + RSAPrivateCrtKey privateKey = (RSAPrivateCrtKey) keyFactory.generatePrivate(keySpec); + + // Derive the public key from the private key + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec( + privateKey.getModulus(), + privateKey.getPublicExponent() + ); + PublicKey publicKey = keyFactory.generatePublic(publicKeySpec); + + PartialRsaKeyPair keyPair = PartialRsaKeyPair.builder() + .privateKey(privateKey) + .publicKey(publicKey) + .build(); + + RsaKeyring.Builder keyringBuilder = RsaKeyring.builder() + .wrappingKeyPair(keyPair); + + if (materialsDescription != null) { + keyringBuilder.materialsDescription(materialsDescription); + } + + return keyringBuilder.build(); + } + + throw new IllegalStateException( + "KeyMaterial must have either aesKey or rsaKey set"); + } catch (Exception e) { + throw new IllegalStateException("Failed to create keyring from KeyMaterial: " + e.getMessage(), e); + } + } +} diff --git a/test-server/java-server/src/main/java/software/amazon/encryption/s3/S3ECJavaTestServer.java b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/S3ECJavaTestServer.java similarity index 67% rename from test-server/java-server/src/main/java/software/amazon/encryption/s3/S3ECJavaTestServer.java rename to test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/S3ECJavaTestServer.java index 8ad437f4..88d5b981 100644 --- a/test-server/java-server/src/main/java/software/amazon/encryption/s3/S3ECJavaTestServer.java +++ b/test-server/java-v4-server/src/main/java/software/amazon/encryption/s3/S3ECJavaTestServer.java @@ -6,16 +6,16 @@ package software.amazon.encryption.s3; import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.encryption.s3.service.S3ECTestServer; +import software.amazon.smithy.java.server.Server; import java.net.URI; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; -import software.amazon.smithy.java.server.Server; -import software.amazon.encryption.s3.service.S3ECTestServer; public class S3ECJavaTestServer implements Runnable { - static final URI endpoint = URI.create("http://localhost:8080"); + static final URI endpoint = URI.create("http://localhost:8088"); public static void main(String[] args) { new S3ECJavaTestServer().run(); @@ -27,16 +27,18 @@ public void run() { // Obviously this can get messy in a real service. // Assume that the tests behave and don't induce weird race conditions. Map clientCache = new ConcurrentHashMap<>(); + Map keyringCache = new ConcurrentHashMap<>(); Server server = Server.builder() - .endpoints(endpoint) - .addService( - S3ECTestServer.builder() - .addCreateClientOperation(new CreateClientOperationImpl(clientCache)) - .addGetObjectOperation(new GetObjectOperationImpl(clientCache)) - .addPutObjectOperation(new PutObjectOperationImpl(clientCache)) - .build()) - .build(); + .endpoints(endpoint) + .addService( + S3ECTestServer.builder() + .addCreateClientOperation(new CreateClientOperationImpl(clientCache, keyringCache)) + .addGetObjectOperation(new GetObjectOperationImpl(clientCache)) + .addPutObjectOperation(new PutObjectOperationImpl(clientCache)) + .addReEncryptOperation(new ReEncryptOperationImpl(clientCache)) + .build()) + .build(); System.out.println("Starting server..."); server.start(); try { diff --git a/test-server/model/client.smithy b/test-server/model/client.smithy index 4de56b5b..11f65f57 100644 --- a/test-server/model/client.smithy +++ b/test-server/model/client.smithy @@ -25,7 +25,44 @@ structure CreateClientOutput { structure KeyMaterial { rsaKey: Blob, aesKey: Blob, - kmsKeyId: String + kmsKeyId: String, + /// Optional materials description for keyring differentiation + /// Used to distinguish between different key materials for rotation enforcement + materialsDescription: MaterialsDescriptionMap +} + +/// Map of materials description key-value pairs +map MaterialsDescriptionMap { + key: String, + value: String +} + +enum CommitmentPolicy { + REQUIRE_ENCRYPT_REQUIRE_DECRYPT + REQUIRE_ENCRYPT_ALLOW_DECRYPT + FORBID_ENCRYPT_ALLOW_DECRYPT +} + +enum EncryptionAlgorithm { + ALG_AES_256_CBC_IV16_NO_KDF + ALG_AES_256_GCM_IV12_TAG16_NO_KDF + ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY +} + +structure InstructionFileConfig { + /// This allows specifying a (non-encrypted) client for languages which + /// support this for instruction files. + /// In general, languages do not require specifying a client; + /// they use the usual wrapped client for instruction file operations, + /// so it is fine to leave it null for now. + /// This also requires a way to create non-encrypted clients which we don't have yet. + /// Some languages (Java) do allow a client to be passed specifically for instruction files, + /// so this should be implemented eventually for full coverage, + /// especially if other languages add this feature. Until then, + /// the Java integ tests are sufficient. + clientId: String, + enableInstructionFilePutObject: Boolean = false, + disableInstructionFile: Boolean = false } structure S3ECConfig { @@ -33,5 +70,8 @@ structure S3ECConfig { enableDelayedAuthenticationMode: Boolean = false, enableLegacyWrappingAlgorithms: Boolean = false, setBufferSize: Long, - keyMaterial: KeyMaterial + keyMaterial: KeyMaterial, + commitmentPolicy: CommitmentPolicy, + encryptionAlgorithm: EncryptionAlgorithm, + instructionFileConfig: InstructionFileConfig, } diff --git a/test-server/model/object.smithy b/test-server/model/object.smithy index 623d8ed3..93e78370 100644 --- a/test-server/model/object.smithy +++ b/test-server/model/object.smithy @@ -21,6 +21,7 @@ resource Object { } read: GetObject put: PutObject + operations: [ReEncrypt] } @idempotent @@ -35,6 +36,14 @@ operation PutObject { @required $key + /// Encryption context/materials description to use for this operation. + /// For most SDKs (Java, .NET, etc.), materials description is embedded in the keyring/materials + /// during client creation and this parameter is typically empty/unused. + /// + /// For C++ SDK: Materials description MUST be passed per-operation via this parameter + /// because the C++ SDK's EncryptionMaterials constructor does not accept materials description. + /// Instead, GetObject/PutObject operations accept a contextMap parameter that becomes the + /// materials description. @httpHeader("Content-Metadata") $metadata @@ -72,7 +81,14 @@ operation GetObject { @required $key - /// Should probably be renamed to be EC specific + /// Encryption context/materials description to use for this operation. + /// For most SDKs (Java, .NET, etc.), materials description is embedded in the keyring/materials + /// during client creation and this parameter is typically empty/unused. + /// + /// For C++ SDK: Materials description MUST be passed per-operation via this parameter + /// because the C++ SDK's EncryptionMaterials constructor does not accept materials description. + /// Instead, GetObject/PutObject operations accept a contextMap parameter that becomes the + /// materials description. @httpHeader("Content-Metadata") $metadata @@ -80,7 +96,16 @@ operation GetObject { @required @notProperty clientID: String - } + + @httpHeader("Range") + @notProperty + range: String + + /// Custom instruction file suffix to use when reading instruction files + @httpHeader("InstructionFileSuffix") + @notProperty + instructionFileSuffix: String + } output := for Object { @httpHeader("Content-Metadata") @@ -93,6 +118,54 @@ operation GetObject { } } +@http(method: "POST", uri: "/object/{bucket}/{key}/reencrypt") +operation ReEncrypt { + input := for Object { + @httpLabel + @required + $bucket + + @httpLabel + @required + $key + + @httpHeader("ClientID") + @required + @notProperty + clientID: String + + /// New key material to use for re-encryption + @httpPayload + @required + @notProperty + newKeyMaterial: KeyMaterial + + /// Custom instruction file suffix for RSA keyring re-encryption + @httpHeader("InstructionFileSuffix") + @notProperty + instructionFileSuffix: String + + /// Whether to enforce rotation by verifying the key has changed + @httpHeader("EnforceRotation") + @notProperty + enforceRotation: Boolean + } + + output := { + @required + bucket: String + + @required + key: String + + @notProperty + instructionFileSuffix: String + + @notProperty + enforceRotation: Boolean + } +} + /// Smithy does not know how to serialize a map list ObjectMetadata { member: String diff --git a/test-server/net-v3-transition-server/.duvet/.gitignore b/test-server/net-v3-transition-server/.duvet/.gitignore new file mode 100644 index 00000000..93956e36 --- /dev/null +++ b/test-server/net-v3-transition-server/.duvet/.gitignore @@ -0,0 +1,3 @@ +reports/ +requirements/ +specification/ \ No newline at end of file diff --git a/test-server/net-v3-transition-server/.duvet/config.toml b/test-server/net-v3-transition-server/.duvet/config.toml new file mode 100644 index 00000000..416dcfb9 --- /dev/null +++ b/test-server/net-v3-transition-server/.duvet/config.toml @@ -0,0 +1,27 @@ +'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json" + +[[source]] +pattern = "**/*.cs" + +# 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/net-v3-transition-server/.gitignore b/test-server/net-v3-transition-server/.gitignore new file mode 100644 index 00000000..4c20cbc8 --- /dev/null +++ b/test-server/net-v3-transition-server/.gitignore @@ -0,0 +1,44 @@ +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# NuGet Packages +*.nupkg +*.snupkg +packages/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# VS Code +.vscode/ + +# macOS +.DS_Store + +# Temporary files +*.tmp +*.temp diff --git a/test-server/net-v3-transition-server/Controllers/ClientController.cs b/test-server/net-v3-transition-server/Controllers/ClientController.cs new file mode 100644 index 00000000..3deeff61 --- /dev/null +++ b/test-server/net-v3-transition-server/Controllers/ClientController.cs @@ -0,0 +1,133 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Amazon.Extensions.S3.Encryption; +using Amazon.Extensions.S3.Encryption.Primitives; +using Microsoft.AspNetCore.Mvc; +using NetV3TransitionServer.Models; +using NetV3TransitionServer.Services; + +namespace NetV3TransitionServer.Controllers; + +[ApiController] +[Route("[controller]")] +public class ClientController(IClientCacheService clientCacheService, ILogger logger) : ControllerBase +{ + [HttpPost] + public IActionResult CreateClient([FromBody] ClientRequest request) + { + // Return 501 for not implemented features by the server + if (request.Config.EnableDelayedAuthenticationMode) + return StatusCode(501, new GenericServerError { Message = "EnableDelayedAuthenticationMode not supported" }); + if (request.Config.SetBufferSize.HasValue) + return StatusCode(501, new GenericServerError { Message = "SetBufferSize not supported" }); + + try + { + EncryptionMaterialsV2 encryptionMaterial; + if (request.Config.KeyMaterial.KmsKeyId != null) + { + // The POST request does not contain encryption context. + // However, encryption context is a required field when using KMS. + // So, we are passing empty dictionary. + var encryptionContext = new Dictionary(); + var kmsKeyId = request.Config.KeyMaterial.KmsKeyId; + encryptionMaterial = new EncryptionMaterialsV2(kmsKeyId, KmsType.KmsContext, encryptionContext); + logger.LogInformation( + "[NET-V3-Transitional] Created EncryptionMaterialsV2: KMS={KmsKeyId}", + kmsKeyId); + } + else if (request.Config.KeyMaterial.RsaKey != null) + { + var rsaKeyBytes = request.Config.KeyMaterial.RsaKey; + var rsaKey = RSA.Create(); + rsaKey.ImportPkcs8PrivateKey(new ReadOnlySpan(rsaKeyBytes), out _); + encryptionMaterial = new EncryptionMaterialsV2(rsaKey, AsymmetricAlgorithmType.RsaOaepSha1); + logger.LogInformation( + "Created EncryptionMaterialsV2: RSA"); + } + else if (request.Config.KeyMaterial.AesKey != null) + { + var aesKeyBytes = request.Config.KeyMaterial.AesKey; + var aes = Aes.Create(); + aes.Key = aesKeyBytes; + encryptionMaterial = new EncryptionMaterialsV2(aes, SymmetricAlgorithmType.AesGcm); + logger.LogInformation( + "[NET-V3-Transitional] Created EncryptionMaterialsV2: AES"); + } else + { + return StatusCode(501, new GenericServerError { Message = "Unknown or missing key material!" }); + } + + var enableLegacyUnauthenticatedModes = request.Config.EnableLegacyUnauthenticatedModes; + var enableLegacyWrappingAlgorithms = request.Config.EnableLegacyWrappingAlgorithms; + var commitmentPolicy = MapCommitmentPolicy(request.Config.CommitmentPolicy); + + // SecurityProfile V2AndLegacy can decrypt from legacy S3EC but V2 cannot + var enableLegacyMode = enableLegacyUnauthenticatedModes || enableLegacyWrappingAlgorithms; + var securityProfile = enableLegacyMode ? SecurityProfile.V2AndLegacy : SecurityProfile.V2; + logger.LogInformation("[NET-V3-Transitional] Created securityProfile= {securityProfile}", securityProfile.ToString()); + + var encryptionAlgorithm = MapEncryptionAlgorithm(request.Config.EncryptionAlgorithm); + // var encryptionAlgorithm = commitmentPolicy == Amazon.Extensions.S3.Encryption.CommitmentPolicy.ForbidEncryptAllowDecrypt ? ContentEncryptionAlgorithm.AesGcm : ContentEncryptionAlgorithm.AesGcmWithCommitment; + logger.LogInformation("[NET-V3-Transitional] Created commitmentPolicy= {commitmentPolicy}", commitmentPolicy); + logger.LogInformation("[NET-V3-Transitional] Created encryptionAlgorithm= {encryptionAlgorithm}", encryptionAlgorithm); + + var configuration = new AmazonS3CryptoConfigurationV2(securityProfile, commitmentPolicy, encryptionAlgorithm); + + // Add retry configuration for throttling + configuration.RetryMode = Amazon.Runtime.RequestRetryMode.Adaptive; + configuration.MaxErrorRetry = 5; + + if (request.Config.InstructionFileConfig?.EnableInstructionFilePutObject == true) + { + configuration.StorageMode = CryptoStorageMode.InstructionFile; + logger.LogInformation("[NET-V3-Transitional] Created StorageMode= InstructionFile"); + } + // Create S3 encryption client + var encryptionClient = new AmazonS3EncryptionClientV2(configuration, encryptionMaterial); + // Add to cache and return client ID + var clientId = clientCacheService.AddClient(encryptionClient); + var response = new ClientResponse { ClientId = clientId }; + + logger.LogInformation("[NET-V3-Transitional] Created S3EC client with ID: {clientId}", clientId); + + return new ContentResult + { + Content = JsonSerializer.Serialize(response), + ContentType = "application/json", + StatusCode = 200 + }; + } + catch (Exception ex) + { + logger.LogError(ex, "[NET-V3-Transitional] Failed to create S3EC client"); + return StatusCode(500, new S3EncryptionClientError + { + Message = $"Failed to create client: {ex.Message}" + }); + } + } + + private static Amazon.Extensions.S3.Encryption.CommitmentPolicy MapCommitmentPolicy(Models.CommitmentPolicy? policy) + { + return policy switch + { + Models.CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT => Amazon.Extensions.S3.Encryption.CommitmentPolicy.RequireEncryptRequireDecrypt, + Models.CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT => Amazon.Extensions.S3.Encryption.CommitmentPolicy.RequireEncryptAllowDecrypt, + Models.CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT => Amazon.Extensions.S3.Encryption.CommitmentPolicy.ForbidEncryptAllowDecrypt, + _ => Amazon.Extensions.S3.Encryption.CommitmentPolicy.ForbidEncryptAllowDecrypt + }; + } + + // This is redundant but useful when tests starts sending EncryptionAlgorithm + private static ContentEncryptionAlgorithm MapEncryptionAlgorithm(Models.EncryptionAlgorithm? algorithm) + { + return algorithm switch + { + Models.EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF => ContentEncryptionAlgorithm.AesGcm, + Models.EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY => ContentEncryptionAlgorithm.AesGcmWithCommitment, + _ => ContentEncryptionAlgorithm.AesGcm + }; + } +} diff --git a/test-server/net-v3-transition-server/Controllers/ObjectController.cs b/test-server/net-v3-transition-server/Controllers/ObjectController.cs new file mode 100644 index 00000000..76548815 --- /dev/null +++ b/test-server/net-v3-transition-server/Controllers/ObjectController.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Amazon.S3.Model; +using Microsoft.AspNetCore.Mvc; +using NetV3TransitionServer.Models; +using NetV3TransitionServer.Services; + +namespace NetV3TransitionServer.Controllers; + +[ApiController] +[Route("[controller]")] +public class ObjectController(IClientCacheService clientCacheService, ILogger logger) : ControllerBase +{ + [HttpPut("{bucket}/{key}")] + public async Task PutObject(string bucket, string key) + { + logger.LogInformation("Starting PutObject"); + var clientId = Request.Headers["clientId"].FirstOrDefault(); + if (string.IsNullOrEmpty(clientId)) + return BadRequest(new GenericServerError { Message = "[NET-V3-Transitional] ClientID header is required" }); + + var client = clientCacheService.GetClient(clientId); + if (client == null) + return NotFound(new GenericServerError { Message = $"[NET-V3-Transitional] No client found for ClientID: {clientId}" }); + + try + { + // Read raw body data + using var memoryStream = new MemoryStream(); + // Request is the HTTP request this method is currently handling + await Request.Body.CopyToAsync(memoryStream); + var bodyBytes = memoryStream.ToArray(); + + // Create put request + var putRequest = new PutObjectRequest + { + BucketName = bucket, + Key = key, + InputStream = new MemoryStream(bodyBytes) + }; + + await client.PutObjectAsync(putRequest); + + var response = new { bucket, key }; + + logger.LogInformation( + "[NET-V3-Transitional] Put object succeeded for bucket={bucket}, key={key} and clientId = {clientId}", + bucket, key, clientId); + return new ContentResult + { + Content = JsonSerializer.Serialize(response), + ContentType = "application/json", + StatusCode = 200 + }; + } + catch (Exception ex) + { + logger.LogError(ex, "[NET-V3-Transitional] Failed to put object from S3 for bucket={bucket}, key={key}", bucket, key); + return StatusCode(500, new S3EncryptionClientError { Message = $"[NET-V3-Transitional] Failed to put object: {ex.Message}" }); + } + } + + [HttpGet("{bucket}/{key}")] + public async Task GetObject(string bucket, string key) + { + logger.LogInformation("Starting GetObject"); + var clientId = Request.Headers["clientId"].FirstOrDefault(); + if (string.IsNullOrEmpty(clientId)) + return BadRequest(new GenericServerError { Message = "[NET-V3-Transitional] ClientID header is required" }); + + var client = clientCacheService.GetClient(clientId); + if (client == null) + return NotFound(new GenericServerError { Message = $"[NET-V3-Transitional] No client found for ClientID: {clientId}" }); + + try + { + var getRequest = new GetObjectRequest + { + BucketName = bucket, + Key = key + }; + var response = await client.GetObjectAsync(getRequest); + logger.LogInformation("[NET-V3-Transitional] Got object from S3 for bucket={bucket}, key={key}", bucket, key); + // Read response body + using var memoryStream = new MemoryStream(); + await response.ResponseStream.CopyToAsync(memoryStream); + var bodyBytes = memoryStream.ToArray(); + + // Convert metadata to content-metadata header format + var metadataList = response.Metadata.Keys + .Select(metaDataKey => $"{metaDataKey}={response.Metadata[metaDataKey]}") + .ToList(); + var metadataStr = string.Join(",", metadataList); + + // Set response headers + Response.Headers["Content-Metadata"] = metadataStr; + + return File(bodyBytes, "application/octet-stream"); + } + catch (Exception ex) + { + logger.LogError(ex, "[NET-V3-Transitional] Failed to get object from S3 for bucket={bucket}, key={key}", bucket, key); + return StatusCode(500, new S3EncryptionClientError { Message = ex.Message }); + } + } +} \ No newline at end of file diff --git a/test-server/net-v3-transition-server/Makefile b/test-server/net-v3-transition-server/Makefile new file mode 100644 index 00000000..eba78e1c --- /dev/null +++ b/test-server/net-v3-transition-server/Makefile @@ -0,0 +1,43 @@ +# Makefile for S3 Encryption Client .NET Testing + +.PHONY: build-server start-server stop-server wait-for-server + +PID_FILE_NET_V3_TRANSITION := net-v3-transition-server.pid +PORT_NET_V3_TRANSITION := 8100 + +build-server: + @echo "Building .NET V3 transition server..." + dotnet build + +start-server: + $(MAKE) start-net-v3-transition-server + +stop-server: + @echo "Stopping .NET V3 Transition server on port $(PORT_NET_V3_TRANSITION)..." + @lsof -ti:$(PORT_NET_V3_TRANSITION) | xargs kill -9 2>/dev/null || true + @if [ -f $(PID_FILE_NET_V3_TRANSITION) ]; then \ + pkill -P $$(cat $(PID_FILE_NET_V3_TRANSITION)) 2>/dev/null || true; \ + kill -9 $$(cat $(PID_FILE_NET_V3_TRANSITION)) 2>/dev/null || true; \ + rm -f $(PID_FILE_NET_V3_TRANSITION); \ + fi + @rm -f server.log + @echo "Server stopped" + +# Start .NET V3 transition server in background +start-net-v3-transition-server: + @echo "Starting .NET 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" \ + dotnet run > server.log 2>&1 & echo $$! > $(PID_FILE_NET_V3_TRANSITION) + @echo ".NET V3 transition server starting..." + +wait-for-server: + $(MAKE) -C .. wait-for-port PORT=$(PORT_NET_V3_TRANSITION) + +duvet: + duvet report + +view-report-mac: + open .duvet/reports/report.html diff --git a/test-server/net-v3-transition-server/Models/ClientRequest.cs b/test-server/net-v3-transition-server/Models/ClientRequest.cs new file mode 100644 index 00000000..07fe8520 --- /dev/null +++ b/test-server/net-v3-transition-server/Models/ClientRequest.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace NetV3TransitionServer.Models; + +public class ClientRequest +{ + [Required] + public ClientConfig Config { get; set; } = new(); +} + +public class ClientConfig +{ + public bool EnableLegacyUnauthenticatedModes { get; set; } = false; + public bool EnableLegacyWrappingAlgorithms { get; set; } = false; + public bool EnableDelayedAuthenticationMode { get; set; } = false; + public long? SetBufferSize { get; set; } + [Required] + public KeyMaterial KeyMaterial { get; set; } = new(); + [JsonPropertyName("commitmentPolicy")] + public CommitmentPolicy? CommitmentPolicy { get; set; } + [JsonPropertyName("encryptionAlgorithm")] + public EncryptionAlgorithm? EncryptionAlgorithm { get; set; } + public InstructionFileConfig? InstructionFileConfig { get; set; } +} + +public class KeyMaterial +{ + public byte[]? RsaKey { get; set; } + public byte[]? AesKey { get; set; } + public string? KmsKeyId { get; set; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CommitmentPolicy +{ + REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + REQUIRE_ENCRYPT_ALLOW_DECRYPT, + FORBID_ENCRYPT_ALLOW_DECRYPT +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EncryptionAlgorithm +{ + ALG_AES_256_CBC_IV16_NO_KDF, + ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY +} + +public class InstructionFileConfig +{ + public string? ClientId { get; set; } + public bool EnableInstructionFilePutObject { get; set; } = false; + public bool DisableInstructionFile { get; set; } = false; +} \ No newline at end of file diff --git a/test-server/net-v3-transition-server/Models/ClientResponse.cs b/test-server/net-v3-transition-server/Models/ClientResponse.cs new file mode 100644 index 00000000..43c94a3e --- /dev/null +++ b/test-server/net-v3-transition-server/Models/ClientResponse.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace NetV3TransitionServer.Models; + +public class ClientResponse +{ + [JsonPropertyName("clientId")] public string ClientId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/test-server/net-v3-transition-server/Models/ErrorModels.cs b/test-server/net-v3-transition-server/Models/ErrorModels.cs new file mode 100644 index 00000000..7fbf6680 --- /dev/null +++ b/test-server/net-v3-transition-server/Models/ErrorModels.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace NetV3TransitionServer.Models; + +public class GenericServerError +{ + [JsonPropertyName("__type")] + public string Type { get; set; } = "software.amazon.encryption.s3#GenericServerError"; + public string Message { get; set; } = string.Empty; +} + +public class S3EncryptionClientError +{ + [JsonPropertyName("__type")] + public string Type { get; set; } = "software.amazon.encryption.s3#S3EncryptionClientError"; + public string Message { get; set; } = string.Empty; +} diff --git a/test-server/net-v3-transition-server/NetV3TransitionServer.csproj b/test-server/net-v3-transition-server/NetV3TransitionServer.csproj new file mode 100644 index 00000000..269f555f --- /dev/null +++ b/test-server/net-v3-transition-server/NetV3TransitionServer.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + false + + + + false + + + + + + + + + + + + + + + + diff --git a/test-server/net-v3-transition-server/Program.cs b/test-server/net-v3-transition-server/Program.cs new file mode 100644 index 00000000..138743c9 --- /dev/null +++ b/test-server/net-v3-transition-server/Program.cs @@ -0,0 +1,17 @@ +using NetV3TransitionServer.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddSingleton(); + +const int port = 8100; + +builder.WebHost.UseUrls($"http://localhost:{port}"); + +var app = builder.Build(); + +app.MapControllers(); + +Console.WriteLine($"Starting server on port {port}"); +app.Run(); diff --git a/test-server/net-v3-transition-server/README.md b/test-server/net-v3-transition-server/README.md new file mode 100644 index 00000000..ea925c73 --- /dev/null +++ b/test-server/net-v3-transition-server/README.md @@ -0,0 +1,66 @@ +# Net-V3-Transition-Server + +A .NET test server for Amazon S3 encryption client .NET v3 transition. + +## Project Structure + +``` +net-v3-transition-server/ +├── Controllers/ # API controllers +├── Models/ # Data models +├── Services/ # Business logic services +├── Program.cs # Application entry point +├── NetV3TransitionServer.csproj # Project file +└── README.md # This file +``` + +## Running the Server + +For S3 Encryption Client v3 transition (runs on port 8100): + +```bash +dotnet run +``` + +## API Endpoints + +### Client Management + +- `POST /Client` - Create a new S3 encryption client + +### Object Operations + +- `PUT /{bucket}/{key}` - Upload an encrypted object to S3 +- `GET /{bucket}/{key}` - Download and decrypt an object from S3 + +All object operations require a `clientId` header to specify which client to use. + +## Example Usage + +### Create a Client + +```bash +curl -i -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":true,"enableLegacyWrappingAlgorithms":true,"keyMaterial":{"kmsKeyId":"arn:aws:kms:us-west-2:370957321024:alias/S3EC-Test-Server-Github-KMS-Key"}, "encryptionContext": {"abc": "b"}, "CommitmentPolicy":"REQUIRE_ENCRYPT_REQUIRE_DECRYPT"}}' \ + http://localhost:8100/client +``` + +### Upload an Object + +```bash +curl -X PUT \ + -H "clientid: 7978763a-a02b-4dea-a5d4-78ef11d13d12" \ + -H "content-type: application/octet-stream" \ + -d "simple-test-input-net" \ + http://localhost:8083/object/s3ec-test-server-github-bucket/cross-lang-test-key-kms-ec-dotnet +``` + +### Download an Object + +```bash +curl -X GET \ + -H "clientid: 7978763a-a02b-4dea-a5d4-78ef11d13d12" \ + http://localhost:8083/object/s3ec-test-server-github-bucket/cross-lang-test-key-kms-ec-dotnet +``` diff --git a/test-server/net-v3-transition-server/Services/ClientCacheService.cs b/test-server/net-v3-transition-server/Services/ClientCacheService.cs new file mode 100644 index 00000000..0e7332ca --- /dev/null +++ b/test-server/net-v3-transition-server/Services/ClientCacheService.cs @@ -0,0 +1,28 @@ +using Amazon.Extensions.S3.Encryption; +using System.Collections.Concurrent; + +namespace NetV3TransitionServer.Services; + +public interface IClientCacheService +{ + string AddClient(AmazonS3EncryptionClientV2 client); + AmazonS3EncryptionClientV2? GetClient(string clientId); +} + +public class ClientCacheService : IClientCacheService +{ + private readonly ConcurrentDictionary _clients = new(); + + public string AddClient(AmazonS3EncryptionClientV2 client) + { + var clientId = Guid.NewGuid().ToString(); + _clients[clientId] = client; + return clientId; + } + + public AmazonS3EncryptionClientV2? GetClient(string clientId) + { + _clients.TryGetValue(clientId, out var client); + return client; + } +} diff --git a/test-server/net-v3-transition-server/s3ec-v3-transition-branch b/test-server/net-v3-transition-server/s3ec-v3-transition-branch new file mode 160000 index 00000000..7a552940 --- /dev/null +++ b/test-server/net-v3-transition-server/s3ec-v3-transition-branch @@ -0,0 +1 @@ +Subproject commit 7a55294068bb3bb7f96226efd6d9edcd1057184b diff --git a/test-server/net-v4-server/.duvet/.gitignore b/test-server/net-v4-server/.duvet/.gitignore new file mode 100644 index 00000000..93956e36 --- /dev/null +++ b/test-server/net-v4-server/.duvet/.gitignore @@ -0,0 +1,3 @@ +reports/ +requirements/ +specification/ \ No newline at end of file diff --git a/test-server/net-v4-server/.duvet/config.toml b/test-server/net-v4-server/.duvet/config.toml new file mode 100644 index 00000000..0548b05c --- /dev/null +++ b/test-server/net-v4-server/.duvet/config.toml @@ -0,0 +1,27 @@ +'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json" + +[[source]] +pattern = "**/*.cs" + +# 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 \ No newline at end of file diff --git a/test-server/net-v4-server/.gitignore b/test-server/net-v4-server/.gitignore new file mode 100644 index 00000000..4c20cbc8 --- /dev/null +++ b/test-server/net-v4-server/.gitignore @@ -0,0 +1,44 @@ +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# NuGet Packages +*.nupkg +*.snupkg +packages/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# VS Code +.vscode/ + +# macOS +.DS_Store + +# Temporary files +*.tmp +*.temp diff --git a/test-server/net-v4-server/Controllers/ClientController.cs b/test-server/net-v4-server/Controllers/ClientController.cs new file mode 100644 index 00000000..2ef8b921 --- /dev/null +++ b/test-server/net-v4-server/Controllers/ClientController.cs @@ -0,0 +1,144 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Amazon.Extensions.S3.Encryption; +using Amazon.Extensions.S3.Encryption.Primitives; +using Microsoft.AspNetCore.Mvc; +using NetV4Server.Models; +using NetV4Server.Services; + +namespace NetV4Server.Controllers; + +[ApiController] +[Route("[controller]")] +public class ClientController(IClientCacheService clientCacheService, ILogger logger) : ControllerBase +{ + [HttpPost] + public IActionResult CreateClient([FromBody] ClientRequest request) + { + // Return 501 for not implemented features by the server + if (request.Config.EnableDelayedAuthenticationMode ?? false) + return StatusCode(501, new GenericServerError { Message = "[NET-V4] EnableDelayedAuthenticationMode not supported" }); + if (request.Config.SetBufferSize.HasValue) + return StatusCode(501, new GenericServerError { Message = "[NET-V4] SetBufferSize not supported" }); + + try + { + EncryptionMaterialsV4 encryptionMaterial; + if (request.Config.KeyMaterial.KmsKeyId != null) + { + // The POST request does not contain encryption context. + // However, encryption context is a required field when using KMS. + // So, we are passing empty dictionary. + var encryptionContext = new Dictionary(); + var kmsKeyId = request.Config.KeyMaterial.KmsKeyId; + encryptionMaterial = new EncryptionMaterialsV4(kmsKeyId, KmsType.KmsContext, encryptionContext); + logger.LogInformation( + "[NET-V4] Created EncryptionMaterialsV4: KMS={KmsKeyId}", + kmsKeyId); + } + else if (request.Config.KeyMaterial.RsaKey != null) + { + var rsaKeyBytes = request.Config.KeyMaterial.RsaKey; + var rsaKey = RSA.Create(); + rsaKey.ImportPkcs8PrivateKey(new ReadOnlySpan(rsaKeyBytes), out _); + encryptionMaterial = new EncryptionMaterialsV4(rsaKey, AsymmetricAlgorithmType.RsaOaepSha1); + logger.LogInformation( + "[NET-V4] Created EncryptionMaterialsV4: RSA"); + } + else if (request.Config.KeyMaterial.AesKey != null) + { + var aesKeyBytes = request.Config.KeyMaterial.AesKey; + var aes = Aes.Create(); + aes.Key = aesKeyBytes; + encryptionMaterial = new EncryptionMaterialsV4(aes, SymmetricAlgorithmType.AesGcm); + logger.LogInformation( + "[NET-V4] Created EncryptionMaterialsV4: AES"); + } else + { + return StatusCode(501, new GenericServerError { Message = "[NET-V4] Unknown or missing key material!" }); + } + var enableLegacyUnauthenticatedModes = request.Config.EnableLegacyUnauthenticatedModes ?? false; + var enableLegacyWrappingAlgorithms = request.Config.EnableLegacyWrappingAlgorithms ?? false; + var commitmentPolicy = MapCommitmentPolicy(request.Config.CommitmentPolicy); + var isSecurityProfileProvided = request.Config.EnableLegacyUnauthenticatedModes.HasValue || request.Config.EnableLegacyWrappingAlgorithms.HasValue; + var isCommitmentPolicyProvided = request.Config.CommitmentPolicy.HasValue; + var useDefaultConf = !isCommitmentPolicyProvided; + + logger.LogInformation("[NET-V4] isSecurityProfileProvided: {isSecurityProfileProvided}, isCommitmentPolicyProvided: {isCommitmentPolicyProvided}, useDefaultConf: {useDefaultConf}", isSecurityProfileProvided, isCommitmentPolicyProvided, useDefaultConf); + + // SecurityProfile V4AndLegacy can decrypt from legacy S3EC but V4 cannot + var enableLegacyMode = enableLegacyUnauthenticatedModes || enableLegacyWrappingAlgorithms; + var securityProfile = enableLegacyMode ? SecurityProfile.V4AndLegacy : SecurityProfile.V4; + + var encryptionAlgorithm = MapEncryptionAlgorithm(request.Config.EncryptionAlgorithm); + + if (!useDefaultConf) + { + logger.LogInformation("[NET-V4] Created securityProfile= {securityProfile}", securityProfile.ToString()); + logger.LogInformation("[NET-V4] Created commitmentPolicy= {commitmentPolicy}", commitmentPolicy); + logger.LogInformation("[NET-V4] Created encryptionAlgorithm= {encryptionAlgorithm}", encryptionAlgorithm); + } else + { + logger.LogInformation("[NET-V4] Using default configuration for securityProfile, commitmentPolicy and encryptionAlgorithm"); + } + + var configuration = useDefaultConf + ? new AmazonS3CryptoConfigurationV4() + : new AmazonS3CryptoConfigurationV4(securityProfile, commitmentPolicy, encryptionAlgorithm); + + // Add retry configuration for throttling + configuration.RetryMode = Amazon.Runtime.RequestRetryMode.Adaptive; + configuration.MaxErrorRetry = 5; + + if (request.Config.InstructionFileConfig?.EnableInstructionFilePutObject == true) + { + configuration.StorageMode = CryptoStorageMode.InstructionFile; + logger.LogInformation("[NET-V3-Transitional] Created StorageMode= InstructionFile"); + } + + // Create S3 encryption client + var encryptionClient = new AmazonS3EncryptionClientV4(configuration, encryptionMaterial); + // Add to cache and return client ID + var clientId = clientCacheService.AddClient(encryptionClient); + var response = new ClientResponse { ClientId = clientId }; + + logger.LogInformation("[NET-V4] Created S3EC client with ID: {clientId}", clientId); + + return new ContentResult + { + Content = JsonSerializer.Serialize(response), + ContentType = "application/json", + StatusCode = 200 + }; + } + catch (Exception ex) + { + logger.LogError(ex, "[NET-V4] Failed to create S3EC client"); + return StatusCode(500, new S3EncryptionClientError + { + Message = $"[NET-V4] Failed to create client: {ex.Message}" + }); + } + } + + private static Amazon.Extensions.S3.Encryption.CommitmentPolicy MapCommitmentPolicy(Models.CommitmentPolicy? policy) + { + return policy switch + { + Models.CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT => Amazon.Extensions.S3.Encryption.CommitmentPolicy.RequireEncryptRequireDecrypt, + Models.CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT => Amazon.Extensions.S3.Encryption.CommitmentPolicy.RequireEncryptAllowDecrypt, + Models.CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT => Amazon.Extensions.S3.Encryption.CommitmentPolicy.ForbidEncryptAllowDecrypt, + _ => Amazon.Extensions.S3.Encryption.CommitmentPolicy.RequireEncryptRequireDecrypt + }; + } + + private static ContentEncryptionAlgorithm MapEncryptionAlgorithm(Models.EncryptionAlgorithm? algorithm) + { + return algorithm switch + { + Models.EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF => ContentEncryptionAlgorithm.AesGcm, + Models.EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY => ContentEncryptionAlgorithm.AesGcmWithCommitment, + _ => ContentEncryptionAlgorithm.AesGcmWithCommitment + }; + } +} diff --git a/test-server/net-v4-server/Controllers/ObjectController.cs b/test-server/net-v4-server/Controllers/ObjectController.cs new file mode 100644 index 00000000..7ebd8fd1 --- /dev/null +++ b/test-server/net-v4-server/Controllers/ObjectController.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Amazon.S3.Model; +using Microsoft.AspNetCore.Mvc; +using NetV4Server.Models; +using NetV4Server.Services; + +namespace NetV4Server.Controllers; + +[ApiController] +[Route("[controller]")] +public class ObjectController(IClientCacheService clientCacheService, ILogger logger) : ControllerBase +{ + [HttpPut("{bucket}/{key}")] + public async Task PutObject(string bucket, string key) + { + logger.LogInformation("Starting PutObject"); + var clientId = Request.Headers["clientId"].FirstOrDefault(); + if (string.IsNullOrEmpty(clientId)) + return BadRequest(new GenericServerError { Message = "[NET-V4] ClientID header is required" }); + + var client = clientCacheService.GetClient(clientId); + if (client == null) + return NotFound(new GenericServerError { Message = $"[NET-V4] No client found for ClientID: {clientId}" }); + + try + { + // Read raw body data + using var memoryStream = new MemoryStream(); + // Request is the HTTP request this method is currently handling + await Request.Body.CopyToAsync(memoryStream); + var bodyBytes = memoryStream.ToArray(); + + // Create put request + var putRequest = new PutObjectRequest + { + BucketName = bucket, + Key = key, + InputStream = new MemoryStream(bodyBytes) + }; + + await client.PutObjectAsync(putRequest); + + var response = new { bucket, key }; + + logger.LogInformation( + "[NET-V4] Put object succeeded for bucket={bucket}, key={key} and clientId = {clientId}", + bucket, key, clientId); + return new ContentResult + { + Content = JsonSerializer.Serialize(response), + ContentType = "application/json", + StatusCode = 200 + }; + } + catch (Exception ex) + { + logger.LogError(ex, "[NET-V4] Failed to put object from S3 for bucket={bucket}, key={key}", bucket, key); + return StatusCode(500, new S3EncryptionClientError { Message = $"Failed to put object: {ex.Message}" }); + } + } + + [HttpGet("{bucket}/{key}")] + public async Task GetObject(string bucket, string key) + { + logger.LogInformation("[NET-V4] Starting GetObject"); + var clientId = Request.Headers["clientId"].FirstOrDefault(); + if (string.IsNullOrEmpty(clientId)) + return BadRequest(new GenericServerError { Message = "[NET-V4] ClientID header is required" }); + + var client = clientCacheService.GetClient(clientId); + if (client == null) + return NotFound(new GenericServerError { Message = $"[NET-V4] No client found for ClientID: {clientId}" }); + + try + { + var getRequest = new GetObjectRequest + { + BucketName = bucket, + Key = key + }; + var response = await client.GetObjectAsync(getRequest); + logger.LogInformation("[NET-V4] Got object from S3 for bucket={bucket}, key={key}", bucket, key); + // Read response body + using var memoryStream = new MemoryStream(); + await response.ResponseStream.CopyToAsync(memoryStream); + var bodyBytes = memoryStream.ToArray(); + + // Convert metadata to content-metadata header format + var metadataList = response.Metadata.Keys + .Select(metaDataKey => $"{metaDataKey}={response.Metadata[metaDataKey]}") + .ToList(); + var metadataStr = string.Join(",", metadataList); + + // Set response headers + Response.Headers["Content-Metadata"] = metadataStr; + + return File(bodyBytes, "application/octet-stream"); + } + catch (Exception ex) + { + logger.LogError(ex, "[NET-V4] Failed to get object from S3 for bucket={bucket}, key={key}", bucket, key); + return StatusCode(500, new S3EncryptionClientError { Message = ex.Message }); + } + } +} \ No newline at end of file diff --git a/test-server/net-v4-server/Makefile b/test-server/net-v4-server/Makefile new file mode 100644 index 00000000..b52bbd49 --- /dev/null +++ b/test-server/net-v4-server/Makefile @@ -0,0 +1,45 @@ +# Makefile for S3 Encryption Client .NET Testing + +.PHONY: start-server stop-server wait-for-server + +PID_FILE_NET_V4 := net-V4-server.pid +PORT_NET_V4 := 8090 + +build-server: + @echo "Building .NET V4 improved server..." + dotnet build + +start-server: + $(MAKE) start-net-V4-server; + +stop-server: + @echo "Stopping .NET V4 Improved server on port $(PORT_NET_V4)..." + @lsof -ti:$(PORT_NET_V4) | xargs kill -9 2>/dev/null || true + @if [ -f $(PID_FILE_NET_V4) ]; then \ + pkill -P $$(cat $(PID_FILE_NET_V4)) 2>/dev/null || true; \ + kill -9 $$(cat $(PID_FILE_NET_V4)) 2>/dev/null || true; \ + rm -f $(PID_FILE_NET_V4); \ + fi + @rm -f server.log + @echo "Server stopped" + +# Start .NET V4 server in background +# This builds first into bin/V4 and runs through dll +# to avoid simultaneous dotnet run conflict +start-net-V4-server: + @echo "Starting .NET 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" \ + dotnet run --no-build > server.log 2>&1 & echo $! > $(PID_FILE_NET_V4) + @echo ".NET V4 server starting..." + +wait-for-server: + $(MAKE) -C .. wait-for-port PORT=$(PORT_NET_V4) + +duvet: + duvet report + +view-report-mac: + open .duvet/reports/report.html diff --git a/test-server/net-v4-server/Models/ClientRequest.cs b/test-server/net-v4-server/Models/ClientRequest.cs new file mode 100644 index 00000000..76623b9d --- /dev/null +++ b/test-server/net-v4-server/Models/ClientRequest.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace NetV4Server.Models; + +public class ClientRequest +{ + [Required] + public ClientConfig Config { get; set; } = new(); +} + +public class ClientConfig +{ + public bool? EnableLegacyUnauthenticatedModes { get; set; } + public bool? EnableLegacyWrappingAlgorithms { get; set; } + public bool? EnableDelayedAuthenticationMode { get; set; } + public long? SetBufferSize { get; set; } + [Required] + public KeyMaterial KeyMaterial { get; set; } = new(); + [JsonPropertyName("commitmentPolicy")] + public CommitmentPolicy? CommitmentPolicy { get; set; } + [JsonPropertyName("encryptionAlgorithm")] + public EncryptionAlgorithm? EncryptionAlgorithm { get; set; } + public InstructionFileConfig? InstructionFileConfig { get; set; } +} + +public class KeyMaterial +{ + public byte[]? RsaKey { get; set; } + public byte[]? AesKey { get; set; } + public string? KmsKeyId { get; set; } +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CommitmentPolicy +{ + REQUIRE_ENCRYPT_REQUIRE_DECRYPT, + REQUIRE_ENCRYPT_ALLOW_DECRYPT, + FORBID_ENCRYPT_ALLOW_DECRYPT +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EncryptionAlgorithm +{ + ALG_AES_256_CBC_IV16_NO_KDF, + ALG_AES_256_GCM_IV12_TAG16_NO_KDF, + ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY +} + +public class InstructionFileConfig +{ + public string? ClientId { get; set; } + public bool EnableInstructionFilePutObject { get; set; } = false; + public bool DisableInstructionFile { get; set; } = false; +} \ No newline at end of file diff --git a/test-server/net-v4-server/Models/ClientResponse.cs b/test-server/net-v4-server/Models/ClientResponse.cs new file mode 100644 index 00000000..b4dbb494 --- /dev/null +++ b/test-server/net-v4-server/Models/ClientResponse.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace NetV4Server.Models; + +public class ClientResponse +{ + [JsonPropertyName("clientId")] public string ClientId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/test-server/net-v4-server/Models/ErrorModels.cs b/test-server/net-v4-server/Models/ErrorModels.cs new file mode 100644 index 00000000..e4b818e3 --- /dev/null +++ b/test-server/net-v4-server/Models/ErrorModels.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace NetV4Server.Models; + +public class GenericServerError +{ + [JsonPropertyName("__type")] + public string Type { get; set; } = "software.amazon.encryption.s3#GenericServerError"; + public string Message { get; set; } = string.Empty; +} + +public class S3EncryptionClientError +{ + [JsonPropertyName("__type")] + public string Type { get; set; } = "software.amazon.encryption.s3#S3EncryptionClientError"; + public string Message { get; set; } = string.Empty; +} diff --git a/test-server/net-v4-server/NetV4Server.csproj b/test-server/net-v4-server/NetV4Server.csproj new file mode 100644 index 00000000..28ddba06 --- /dev/null +++ b/test-server/net-v4-server/NetV4Server.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + false + NetV2V3Server + + + + false + + + + + + + + + + + + + + + + diff --git a/test-server/net-v4-server/Program.cs b/test-server/net-v4-server/Program.cs new file mode 100644 index 00000000..23cf79d9 --- /dev/null +++ b/test-server/net-v4-server/Program.cs @@ -0,0 +1,17 @@ +using NetV4Server.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddSingleton(); + +const int port = 8090; + +builder.WebHost.UseUrls($"http://localhost:{port}"); + +var app = builder.Build(); + +app.MapControllers(); + +Console.WriteLine($"Starting server on port {port}"); +app.Run(); diff --git a/test-server/net-v4-server/README.md b/test-server/net-v4-server/README.md new file mode 100644 index 00000000..487d8471 --- /dev/null +++ b/test-server/net-v4-server/README.md @@ -0,0 +1,72 @@ +# Net-V4-Server + +A .NET test server for Amazon S3 encryption client .NET v4. + +## Project Structure + +``` +net-v4-server/ +├── Controllers/ # API controllers +├── Models/ # Data models +├── Services/ # Business logic services +├── Program.cs # Application entry point +├── NetV2V3Server.csproj # Project file +└── README.md # This file +``` + +## Running the Server + +For S3 Encryption Client v2 (runs on port 8083): + +```bash +dotnet run -p:S3EncryptionVersion=v2 +``` + +For S3 Encryption Client v3 (runs on port 8084): + +```bash +dotnet run -p:S3EncryptionVersion=v3 +``` + +## API Endpoints + +### Client Management + +- `POST /Client` - Create a new S3 encryption client + +### Object Operations + +- `PUT /{bucket}/{key}` - Upload an encrypted object to S3 +- `GET /{bucket}/{key}` - Download and decrypt an object from S3 + +All object operations require a `clientId` header to specify which client to use. + +## Example Usage + +### Create a Client + +```bash +curl -i -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":{"keyMaterial":{"kmsKeyId":"arn:aws:kms:us-west-2:370957321024:alias/S3EC-Test-Server-Github-KMS-Key"}, "encryptionContext": {"abc": "b"}, "CommitmentPolicy":"FORBID_ENCRYPT_ALLOW_DECRYPT"}}' \ + http://localhost:8090/client +``` + +### Upload an Object + +```bash +curl -X PUT \ + -H "clientid: 7978763a-a02b-4dea-a5d4-78ef11d13d12" \ + -H "content-type: application/octet-stream" \ + -d "simple-test-input-net" \ + http://localhost:8083/object/s3ec-test-server-github-bucket/cross-lang-test-key-kms-ec-dotnet +``` + +### Download an Object + +```bash +curl -X GET \ + -H "clientid: 7978763a-a02b-4dea-a5d4-78ef11d13d12" \ + http://localhost:8083/object/s3ec-test-server-github-bucket/cross-lang-test-key-kms-ec-dotnet +``` diff --git a/test-server/net-v4-server/Services/ClientCacheService.cs b/test-server/net-v4-server/Services/ClientCacheService.cs new file mode 100644 index 00000000..55764152 --- /dev/null +++ b/test-server/net-v4-server/Services/ClientCacheService.cs @@ -0,0 +1,28 @@ +using Amazon.Extensions.S3.Encryption; +using System.Collections.Concurrent; + +namespace NetV4Server.Services; + +public interface IClientCacheService +{ + string AddClient(AmazonS3EncryptionClientV4 client); + AmazonS3EncryptionClientV4? GetClient(string clientId); +} + +public class ClientCacheService : IClientCacheService +{ + private readonly ConcurrentDictionary _clients = new(); + + public string AddClient(AmazonS3EncryptionClientV4 client) + { + var clientId = Guid.NewGuid().ToString(); + _clients[clientId] = client; + return clientId; + } + + public AmazonS3EncryptionClientV4? GetClient(string clientId) + { + _clients.TryGetValue(clientId, out var client); + return client; + } +} diff --git a/test-server/net-v4-server/s3ec-net-v4-improved b/test-server/net-v4-server/s3ec-net-v4-improved new file mode 160000 index 00000000..9b628b06 --- /dev/null +++ b/test-server/net-v4-server/s3ec-net-v4-improved @@ -0,0 +1 @@ +Subproject commit 9b628b06e5c1bf12696c752afb2631c38cae11f9 diff --git a/test-server/php-v2-transition-server/.duvet/.gitignore b/test-server/php-v2-transition-server/.duvet/.gitignore new file mode 100644 index 00000000..93956e36 --- /dev/null +++ b/test-server/php-v2-transition-server/.duvet/.gitignore @@ -0,0 +1,3 @@ +reports/ +requirements/ +specification/ \ No newline at end of file diff --git a/test-server/php-v2-transition-server/.duvet/config.toml b/test-server/php-v2-transition-server/.duvet/config.toml new file mode 100644 index 00000000..64b00927 --- /dev/null +++ b/test-server/php-v2-transition-server/.duvet/config.toml @@ -0,0 +1,24 @@ +'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json" + +[[source]] +pattern = "local-php-sdk/src/S3/**/*.php" + +[[source]] +pattern = "local-php-sdk/src/Crypto/**/*.php" + +# Include required specifications here +[[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/key-derivation.md" + +[report.html] +enabled = true + +# Enable snapshots to prevent requirement coverage regressions +[report.snapshot] +enabled = false diff --git a/test-server/php-v2-transition-server/.gitignore b/test-server/php-v2-transition-server/.gitignore new file mode 100644 index 00000000..07108589 --- /dev/null +++ b/test-server/php-v2-transition-server/.gitignore @@ -0,0 +1,4 @@ +vendor/* +cookies.txt +server.pid +composer.lock \ No newline at end of file diff --git a/test-server/php-v2-transition-server/Makefile b/test-server/php-v2-transition-server/Makefile new file mode 100644 index 00000000..a3d038de --- /dev/null +++ b/test-server/php-v2-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 := 8099 + +build-server: + @echo "Building PHP V2 Transition server..." + composer install + +start-server: + @echo "Starting PHP V2 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" \ + composer run start --timeout=0 > server.log 2>&1 & echo $$! > $(PID_FILE) + @echo "PHP V2 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/php-v2-transition-server/composer.json b/test-server/php-v2-transition-server/composer.json new file mode 100644 index 00000000..6a0f263b --- /dev/null +++ b/test-server/php-v2-transition-server/composer.json @@ -0,0 +1,36 @@ +{ + "name": "aws/s3ec-php-v2-transition-test-server", + "description": "PHP V2 Transition implementation of the S3EC Test Server framework", + "type": "project", + "license": "Apache-2.0", + "repositories": [ + { + "type": "path", + "url": "./local-php-sdk", + "options": { + "symlink": true + } + } + ], + "require": { + "php": ">=7.4", + "aws/aws-sdk-php": "@dev", + "ramsey/uuid": "^4.9" + }, + "autoload": { + "psr-4": { + "S3EC\\PhpV2Server\\": "src/" + } + }, + "scripts": { + "start": [ + "php -S 0.0.0.0:8099 src/index.php" + ] + }, + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.1" + } + } +} \ No newline at end of file diff --git a/test-server/php-v2-transition-server/local-php-sdk b/test-server/php-v2-transition-server/local-php-sdk new file mode 120000 index 00000000..4610ddb9 --- /dev/null +++ b/test-server/php-v2-transition-server/local-php-sdk @@ -0,0 +1 @@ +../php-v3-server/local-php-sdk \ No newline at end of file diff --git a/test-server/php-v2-transition-server/src/client.php b/test-server/php-v2-transition-server/src/client.php new file mode 100644 index 00000000..534d47a7 --- /dev/null +++ b/test-server/php-v2-transition-server/src/client.php @@ -0,0 +1,82 @@ +toString(); + $kmsKeyId = $keyMaterial["kmsKeyId"] ?? null; + $commitmentPolicy = $configData['commitmentPolicy'] ?? "FORBID_ENCRYPT_ALLOW_DECRYPT"; + $instFileConfig = $configData['instructionFileConfig'] ?? null; + $instFilePut = false; + if ($instFileConfig != null) { + $instFilePut = $instFileConfig['enableInstructionFilePutObject'] ?? false; + } + + if ($configData == []) { + return GenericServerError("Invalid config in request body", 400); + } + if (($keyMaterial || $kmsKeyId) === null) { + return GenericServerError("Invalid keyMaterial in config", 400); + } + if ($commitmentPolicy !== "FORBID_ENCRYPT_ALLOW_DECRYPT") { + return GenericServerError( + "Transition server only supports FORBID_ENCRYPT_ALLOW_DECRYPT" + . "commitment policy but received {$commitmentPolicy}" + ); + } + + // Store client configuration instead of objects (AWS objects can't be serialized) + $_SESSION['s3ecCache'][$clientId] = [ + 's3Config' => [ + 'region' => 'us-west-2', + 'version' => 'latest', + 'http' => [ + 'debug' => false, + 'verify' => true, + 'curl' => [ + CURLOPT_VERBOSE => false, + CURLOPT_NOPROGRESS => true + ] + ] + ], + 'kmsConfig' => [ + 'region' => 'us-west-2', + 'version' => 'latest', + 'http' => [ + 'debug' => false, + 'verify' => true, + 'curl' => [ + CURLOPT_VERBOSE => false, + CURLOPT_NOPROGRESS => true + ] + ] + ], + 'kmsKeyId' => $kmsKeyId, + 'legacy' => $legacyAlgorithms, + 'commitmentPolicy' => $commitmentPolicy, + 'instFilePut' => $instFilePut, + 'created' => time() + ]; + + // Auto-update cookies.txt with current session ID so tests can access cached clients + writeSessionIdToCookiesFile(session_id()); + + header("Content-Type: application/json"); + return json_encode([ + 'clientId' => $clientId, + ]); +} diff --git a/test-server/php-v2-transition-server/src/errors.php b/test-server/php-v2-transition-server/src/errors.php new file mode 100644 index 00000000..67449c11 --- /dev/null +++ b/test-server/php-v2-transition-server/src/errors.php @@ -0,0 +1,42 @@ + 'GenericServerError', + 'message' => $message + ]; + + return json_encode($errorResponse); +} + +/** + * Used for modeled errors, e.g. errors thrown by the S3EC + * Tests SHOULD expect this error in negative tests. + * + * @param string $message The error message to include in the response + * @return string JSON-encoded error response + */ +function S3EncryptionClientError($message) +{ + http_response_code(500); + header('Content-Type: application/json'); + + $errorResponse = [ + "__type" => "software.amazon.encryption.s3#S3EncryptionClientError", + 'message' => $message + ]; + + return json_encode($errorResponse); +} diff --git a/test-server/php-v2-transition-server/src/get_object.php b/test-server/php-v2-transition-server/src/get_object.php new file mode 100644 index 00000000..656a337a --- /dev/null +++ b/test-server/php-v2-transition-server/src/get_object.php @@ -0,0 +1,104 @@ + $legacy, + '@MaterialsProvider' => $materialProvider, + '@KmsEncryptionContext' => $encryptionContext, + '@CommitmentPolicy' => $commitmentPolicy, + 'Bucket' => $bucket, + 'Key' => $key, + ]; + + // Add custom instruction file suffix if provided + if (!is_null($instructionFileSuffix) && !empty($instructionFileSuffix)) { + $getObjectParams['@InstructionFileSuffix'] = $instructionFileSuffix; + } + + $result = $s3ec->getObject($getObjectParams); + + // Capture and discard any unwanted output from AWS SDK + $unwantedOutput = ob_get_clean(); + if (!empty($unwantedOutput)) { + error_log("AWS SDK produced unexpected output: " . strlen($unwantedOutput) . " bytes"); + } + + $body = $result['Body']->getContents(); + $formattedMetadata = formatMetadataForResponse($result["Metadata"]); + + // Now set headers safely + header("Content-Metadata: " . $formattedMetadata); + header("Content-Type: application/octet-stream"); + header("Content-Length: " . strlen($body)); + return $body; + } catch (InvalidArgumentException $e) { + // Clean up output buffer if still active + if (ob_get_level()) { + ob_end_clean(); + } + return GenericServerError("Invalid argument: " . $e->getMessage(), 400); + } catch (Exception $e) { + // Clean up output buffer if still active + if (ob_get_level()) { + ob_end_clean(); + } + if (strpos($e->getMessage(), "@SecurityProfile=V2") !== false) { + return S3EncryptionClientError($e->getMessage() . " " . "Enable legacy wrapping algorithms to use legacy key wrapping algorithm: kms"); + } elseif (strpos($e->getMessage(), "One or more reserved keys found in Instruction file when they should not be present.") !== false) { + return S3EncryptionClientError($e->getMessage()); + } elseif (strpos($e->getMessage(), "Expected a V3 envelope but was unable to constuct one.") !== false) { + return S3EncryptionClientError($e->getMessage()); + } elseif (strpos($e->getMessage(), "Malformed metadata envelope.") !== false) { + return S3EncryptionClientError($e->getMessage()); + } else { + error_log("This is the error: " . $e->getMessage()); + return GenericServerError("Server error: " . $e->getMessage(), 500); + } + } +} diff --git a/test-server/php-v2-transition-server/src/index.php b/test-server/php-v2-transition-server/src/index.php new file mode 100644 index 00000000..167834e0 --- /dev/null +++ b/test-server/php-v2-transition-server/src/index.php @@ -0,0 +1,295 @@ += 7 && $parts[5] === 'PHPSESSID') { + error_log("Found session ID in cookies.txt: " . $parts[6]); + return $parts[6]; // Return the session ID value + } + } + + error_log("No PHPSESSID found in cookies.txt file"); + return null; +} + +// Function to write session ID to cookies.txt file +function writeSessionIdToCookiesFile($sessionId) +{ + $cookiesFile = __DIR__ . '/../cookies.txt'; + + // Create Netscape cookie format entry + $cookieLine = "localhost\tFALSE\t/\tFALSE\t0\tPHPSESSID\t$sessionId"; + + // Write header and cookie entry + $content = "# Netscape HTTP Cookie File\n"; + $content .= "# https://curl.se/docs/http-cookies.html\n"; + $content .= "# This file was generated by libcurl! Edit at your own risk.\n\n"; + $content .= $cookieLine . "\n"; + + $result = file_put_contents($cookiesFile, $content); + + if ($result === false) { + error_log("Failed to write session ID to cookies.txt file: $cookiesFile"); + return false; + } + + error_log("Successfully wrote session ID to cookies.txt: $sessionId"); + return true; +} + +set_time_limit(600); +// Start session to persist cache across requests +// First try to use session ID from cookies.txt if available +$sessionId = getSessionIdFromCookiesFile(); +if ($sessionId) { + session_id($sessionId); +} +session_start(); + +// Initialize session cache if it doesn't exist +if (!isset($_SESSION['s3ecCache'])) { + $_SESSION['s3ecCache'] = []; +} + +// Simple router class +class SimpleRouter +{ + private $routes = []; + + public function addRoute($method, $path, $handler) + { + $this->routes[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'handler' => $handler + ]; + } + + public function handleRequest() + { + $method = $_SERVER['REQUEST_METHOD']; + $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + + foreach ($this->routes as $route) { + if ($route['method'] === $method) { + $params = $this->matchPathWithParams($route['path'], $path); + if ($params !== false) { + return call_user_func($route['handler'], $params); + } + } + } + + // Default 404 response + http_response_code(404); + return json_encode(['error' => 'Not Found']); + } + + private function matchPathWithParams($routePath, $requestPath) + { + // Handle exact matches first (for routes without parameters) + if ($routePath === $requestPath) { + return []; + } + + // Convert route path like '/object/{bucket}/{key}' to regex + $pattern = preg_replace('/\{([^}]+)\}/', '([^/]+)', $routePath); + $pattern = '/^' . str_replace('/', '\/', $pattern) . '$/'; + + if (preg_match($pattern, $requestPath, $matches)) { + array_shift($matches); // Remove full match + + // Extract parameter names + preg_match_all('/\{([^}]+)\}/', $routePath, $paramNames); + $params = []; + + foreach ($paramNames[1] as $index => $paramName) { + $params[$paramName] = $matches[$index] ?? null; + } + + return $params; + } + + return false; + } +} + +// Helper function to get cached client by ID +function getCachedClient($clientId) +{ + if (!isset($_SESSION['s3ecCache'][$clientId])) { + return null; + } + + $config = $_SESSION['s3ecCache'][$clientId]; + + // Recreate the AWS clients from stored configuration + $s3Client = new S3Client($config['s3Config']); + $encryptionClient = new S3EncryptionClientV2($s3Client); + + $kmsClient = new KmsClient($config['kmsConfig']); + $materialsProvider = new KmsMaterialsProviderV2($kmsClient, $config['kmsKeyId']); + + return [ + 's3Client' => $s3Client, + 'encryptionClient' => $encryptionClient, + 'materialsProvider' => $materialsProvider, + 'config' => $config + ]; +} + +function createDefaultClientTuple(): array +{ + $s3Client = new S3Client([ + 'region' => 'us-west-2', + 'version' => 'latest', + 'http' => [ + 'debug' => false, + 'verify' => true, + 'curl' => [ + CURLOPT_VERBOSE => false, + CURLOPT_NOPROGRESS => true + ] + ] + ]); + $encryptionClient = new S3EncryptionClientV2($s3Client); + + $kmsClient = new KmsClient([ + 'region' => 'us-west-2', + 'version' => 'latest', + 'http' => [ + 'debug' => false, + 'verify' => true, + 'curl' => [ + CURLOPT_VERBOSE => false, + CURLOPT_NOPROGRESS => true + ] + ] + ]); + $materialsProvider = new KmsMaterialsProviderV2($kmsClient, 'arn:aws:kms:us-west-2:370957321024:alias/S3EC-Test-Server-Github-KMS-Key'); + + return [ + 'encryptionClient' => $encryptionClient, + 'materialsProvider' => $materialsProvider + ]; +} + +function metadataStringToMap($metadata): array +{ + $md = []; + + if (empty($metadata)) { + return $md; + } + + $mdList = explode(',', $metadata); + + foreach ($mdList as $entry) { + $parts = explode(']:[', $entry); + + if (count($parts) === 2) { + $key = substr($parts[0], 1); + $value = substr($parts[1], 0, -1); + $md[$key] = $value; + } else { + throw new InvalidArgumentException("Malformed metadata list entry: " . $entry); + } + } + + return $md; +} +function formatMetadataForResponse($metadata) +{ + $metadataList = []; + // Handle different metadata input types + if (is_array($metadata)) { + // If it's an associative array (like Python dict) + foreach ($metadata as $key => $value) { + $metadataList[] = $key . '=' . $value; + } + } elseif (is_string($metadata) && !empty($metadata)) { + // If it's already a string, assume it's in the correct format + return $metadata; + } + + // Convert array to comma-separated string + return implode(',', $metadataList); +} + +// Initialize router +$router = new SimpleRouter(); + +// Add basic routes +$router->addRoute('GET', '/', function () { + return json_encode([ + 'service' => 'S3EC PHP v2 Test Server', + 'status' => 'running', + 'port' => 8087, + 'endpoints' => [ + 'GET /' => 'Server status', + 'POST /client' => 'Create an S3EncryptionClient and cache it.', + 'GET /object/{bucket}/{key}' => 'Handle GET requests to /object/{bucket}/{key} by using the S3EncryptionClient to make a GetObject request to S3.', + 'PUT /object/{bucket}/{key}' => 'Handle PUT requests to /object/{bucket}/{key} by using the S3EncryptionClient to make a PutObject request to S3.', + ] + ]); +}); + +$router->addRoute('GET', '/cache', function () { + return json_encode([ + 'sessionId' => session_id(), + 'sessionStatus' => session_status(), + 'totalCachedClients' => count($_SESSION['s3ecCache'] ?? []), + 'allClientIds' => array_keys($_SESSION['s3ecCache'] ?? []), + 'cacheDetails' => $_SESSION['s3ecCache'] ?? [] + ]); +}); + +$router->addRoute('GET', '/object/{bucket}/{key}', function ($params) { + return handleGetObject($params); +}); + +$router->addRoute('PUT', '/object/{bucket}/{key}', function ($params) { + return handlePutObject($params); +}); + +$router->addRoute('POST', '/client', function () { + return handleCreateClient(); +}); + +// Handle the request and output response +$result = $router->handleRequest(); +if ($result !== false) { + echo $result; +} diff --git a/test-server/php-v2-transition-server/src/put_object.php b/test-server/php-v2-transition-server/src/put_object.php new file mode 100644 index 00000000..405257cc --- /dev/null +++ b/test-server/php-v2-transition-server/src/put_object.php @@ -0,0 +1,79 @@ + 'gcm', + 'KeySize' => 256, + ]; + $legacyConfig = $s3ecClientTuple["legacy"] ?? false; + $legacy = null; + if ($legacyConfig === false) { + $legacy = "V2"; + } else { + $legacy = "V2_AND_LEGACY"; + } + $strategy = $s3ecClientTuple["config"]["instFilePut"] ? + new InstructionFileMetadataStrategy($s3Client) : + new HeadersMetadataStrategy(); + try { + $result = $s3ec->putObject([ + '@SecurityProfile' => $legacy, + '@MaterialsProvider' => $materialProvider, + '@KmsEncryptionContext' => $encryptionContext, + '@MetadataStrategy' => $strategy, + '@CipherOptions' => $cipherOptions, + 'Bucket' => $bucket, + 'Key' => $key, + 'Body' => $rawBody, + ]); + + header("Content-Type: application/json"); + return json_encode([ + "bucket" => $bucket, + "key" => $key, + // php for some reason blows java's heap if we pass the metadata + // "metadata" => $encryptionContext + ]); + + } catch (InvalidArgumentException $e) { + return S3EncryptionClientError("Invalid argument: " . $e->getMessage()); + } catch (Exception $e) { + return GenericServerError("Server error: " . $e->getMessage()); + } +} diff --git a/test-server/php-v3-server/.duvet/.gitignore b/test-server/php-v3-server/.duvet/.gitignore new file mode 100644 index 00000000..93956e36 --- /dev/null +++ b/test-server/php-v3-server/.duvet/.gitignore @@ -0,0 +1,3 @@ +reports/ +requirements/ +specification/ \ No newline at end of file diff --git a/test-server/php-v3-server/.duvet/config.toml b/test-server/php-v3-server/.duvet/config.toml new file mode 100644 index 00000000..d7627473 --- /dev/null +++ b/test-server/php-v3-server/.duvet/config.toml @@ -0,0 +1,39 @@ +'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json" + +[[source]] +pattern = "local-php-sdk/src/S3/**/*.php" + +[[source]] +pattern = "local-php-sdk/src/Crypto/**/*.php" + +[[source]] +pattern = "local-php-sdk/tests/S3/**/*.php" + +[[source]] +pattern = "local-php-sdk/tests/Crypto/**/*.php" + +[[source]] +pattern = "compliance_exceptions/*.txt" + +# Include required specifications here +[[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/client.md" +[[specification]] +source = "../specification/s3-encryption/decryption.md" +[[specification]] +source = "../specification/s3-encryption/encryption.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/php-v3-server/.gitignore b/test-server/php-v3-server/.gitignore new file mode 100644 index 00000000..07108589 --- /dev/null +++ b/test-server/php-v3-server/.gitignore @@ -0,0 +1,4 @@ +vendor/* +cookies.txt +server.pid +composer.lock \ No newline at end of file diff --git a/test-server/php-v3-server/Makefile b/test-server/php-v3-server/Makefile new file mode 100644 index 00000000..9460d4ed --- /dev/null +++ b/test-server/php-v3-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 := 8093 + +build-server: + @echo "Building PHP V3 server..." + composer install + +start-server: + @echo "Starting PHP V3 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" \ + composer run start --timeout=0 > server.log 2>&1 & echo $$! > $(PID_FILE) + @echo "PHP 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/php-v3-server/README.md b/test-server/php-v3-server/README.md new file mode 100644 index 00000000..284c6e97 --- /dev/null +++ b/test-server/php-v3-server/README.md @@ -0,0 +1,66 @@ +# S3EC PHP v3 Test Server + +This is the PHP V3 implementation of the S3ECTestServer framework. It provides a server implementation for testing S3 Encryption Client functionality. + +## Overview + +The S3ECPhpV3TestServer implements the S3ECTestServer service defined in the shared Smithy model. It provides endpoints for: + +- Creating S3 Encryption Clients with session-based caching +- Putting objects with encryption +- Getting and decrypting objects + +## Starting the Server + +### Method 1: Using Composer (Recommended) +```bash +composer run start +``` + +The server will start on port `8093`. + +## Available Endpoints + +### Server Status +- **GET /** - Returns server status and available endpoints + +### Client Management +- **POST /client** - Creates an S3EncryptionClient and caches it with session persistence +- **GET /cache** - Shows current session state and cached clients (for debugging) + +### Object Operations +- **GET /object/{bucket}/{key}** - Handle GET requests using the S3EncryptionClient +- **PUT /object/{bucket}/{key}** - Handle PUT requests using the S3EncryptionClient + +## Testing with curl + +### Important: Session Cookie Management + +To properly test the server and maintain session persistence, you **must** use cookies with curl: + +#### First Request (creates session cookie): +```bash +curl -X POST http://localhost:8093/client \ + -H "Content-Type: application/json" \ + -v +``` + +#### Subsequent Requests (reuses session cookie): +```bash +curl -X POST http://localhost:8093/client \ + -H "Content-Type: application/json" \ + -v +``` + +#### Check Cache Status: +```bash +curl http://localhost:8093/cache \ + -b cookies.txt +``` + +#### Helpful Notes +- **Session Storage**: Client configurations are stored in `$_SESSION['s3ecCache']` +- **Object Recreation**: AWS SDK objects are recreated from stored configuration (they cannot be serialized) +AWS SDK obbjects cannot be serialized due to internal resources and closures. +- **Helper Function**: `getCachedClient($clientId)` retrieves and recreates clients from cache +- **Debugging**: Enhanced logging and `/cache` endpoint for troubleshooting diff --git a/test-server/php-v3-server/compliance_exceptions/client.txt b/test-server/php-v3-server/compliance_exceptions/client.txt new file mode 100644 index 00000000..0efb20bd --- /dev/null +++ b/test-server/php-v3-server/compliance_exceptions/client.txt @@ -0,0 +1,170 @@ +// +// The PHP V3 implementation is missing the following features: +// +// 1. Client Configuration Options: +// - Legacy algorithm support controls (wrapping algorithms, unauthenticated modes) +// - Uses V3/V3_AND_LEGACY instead +// - Delayed authentication mode configuration +// - Buffer size configuration for memory management +// - Raw keyring material (RSA, AES) +// - SDK client configuration inheritance (credentials, KMS client config) +// - Custom randomness source configuration +// +// 2. Api Operations: +// - DeleteObject and DeleteObjects (with instruction file cleanup) +// - Multipart upload operations (UploadPart, CompleteMultipartUpload, AbortMultipartUpload) +// - ReEncryptInstructionFile for key rotation +// - Non-encryption related S3 operations + +//= ../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#cryptographic-materials +//= type=exception +//# If both a CMM and a Keyring are provided, the S3EC MUST throw an exception. + +//= ../specification/s3-encryption/client.md#cryptographic-materials +//= type=exception +//# When a Keyring is provided, the S3EC MUST create an instance of the DefaultCMM using the provided Keyring. + +//= ../specification/s3-encryption/client.md#enable-legacy-wrapping-algorithms +//= type=exception +//# The option to enable legacy wrapping algorithms MUST be set to false by default. + +//= ../specification/s3-encryption/client.md#enable-legacy-unauthenticated-modes +//= type=exception +//# 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 +//= type=exception +//# The option to enable legacy unauthenticated modes MUST be set to false by default. + +//= ../specification/s3-encryption/client.md#enable-legacy-unauthenticated-modes +//= type=exception +//# When enabled, the S3EC MUST be able to decrypt objects encrypted with all content encryption algorithms (both legacy and fully supported). + +//= ../specification/s3-encryption/client.md#enable-legacy-unauthenticated-modes +//= type=exception +//# When disabled, the S3EC MUST NOT decrypt objects encrypted using legacy content encryption algorithms; +//# it MUST throw an exception when attempting to decrypt an object encrypted with a legacy content encryption algorithm. + +//= ../specification/s3-encryption/client.md#enable-delayed-authentication +//= type=exception +//# The S3EC MUST support the option to enable or disable Delayed Authentication mode. + +//= ../specification/s3-encryption/client.md#enable-delayed-authentication +//= type=exception +//# Delayed Authentication mode MUST be set to false by default. + +//= ../specification/s3-encryption/client.md#enable-delayed-authentication +//= type=exception +//# When enabled, the S3EC MAY release plaintext from a stream which has not been authenticated. + +//= ../specification/s3-encryption/client.md#enable-delayed-authentication +//= type=exception +//# When disabled the S3EC MUST NOT release plaintext from a stream which has not been authenticated. + +//= ../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. + +//= ../specification/s3-encryption/client.md#set-buffer-size +//= type=exception +//# 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 +//# If Delayed Authentication mode is disabled, and no buffer size is provided, the S3EC MUST set the buffer size to a reasonable default. + +//= ../specification/s3-encryption/client.md#cryptographic-materials +//= type=exception +//# The S3EC MAY accept key material directly. + +//= ../specification/s3-encryption/client.md#inherited-sdk-configuration +//= type=exception +//# The S3EC MAY support directly configuring the wrapped SDK clients through its initialization. + +//= ../specification/s3-encryption/client.md#inherited-sdk-configuration +//= type=exception +//# For example, the S3EC MAY accept a credentials provider instance during its initialization. + +//= ../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 S3 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. + +//= ../specification/s3-encryption/client.md#randomness +//= type=exception +//# The S3EC MAY accept a source of randomness during client initialization. + +//= ../specification/s3-encryption/client.md#required-api-operations +//= type=exception +//# - DeleteObject MUST be implemented by the S3EC. + +//= ../specification/s3-encryption/client.md#required-api-operations +//= type=exception +//# - DeleteObject MUST delete the given object key. + +//= ../specification/s3-encryption/client.md#required-api-operations +//= type=exception +//# - DeleteObject MUST delete the associated instruction file using the default instruction file suffix. + +//= ../specification/s3-encryption/client.md#required-api-operations +//= type=exception +//# - DeleteObjects MUST be implemented by the S3EC. + +//= ../specification/s3-encryption/client.md#required-api-operations +//= type=exception +//# - DeleteObjects MUST delete each of the given objects. + +//= ../specification/s3-encryption/client.md#required-api-operations +//= type=exception +//# - 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 +//# - UploadPart MAY be implemented by the S3EC. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - UploadPart MUST encrypt each part. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - Each part MUST be encrypted in sequence. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - Each part MUST be encrypted using the same cipher instance for each part. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - CompleteMultipartUpload MAY be implemented by the S3EC. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - CompleteMultipartUpload MUST complete the multipart upload. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - AbortMultipartUpload MAY be implemented by the S3EC. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - AbortMultipartUpload MUST abort the multipart upload. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - ReEncryptInstructionFile MAY be implemented by the S3EC. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - ReEncryptInstructionFile MUST decrypt the instruction file's encrypted data key for the given object using the client's CMM. + +//= ../specification/s3-encryption/client.md#optional-api-operations +//= type=exception +//# - ReEncryptInstructionFile MUST re-encrypt the plaintext data key with a provided keyring. diff --git a/test-server/php-v3-server/compliance_exceptions/content-metadata-strategy.txt b/test-server/php-v3-server/compliance_exceptions/content-metadata-strategy.txt new file mode 100644 index 00000000..bb86da72 --- /dev/null +++ b/test-server/php-v3-server/compliance_exceptions/content-metadata-strategy.txt @@ -0,0 +1,34 @@ +// +// The PHP V3 implementation is missing the following features: +// +// 1. METADATA ENCODING: +// - S3 Server "double encoding" support for proper metadata decoding +// +// 2. INSTRUCTION FILE OPERATIONS: +// - Re-encryption/key rotation via instruction files +// - Custom instruction file suffix support for GetObject requests +// + +//= ../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/metadata-strategy.md#object-metadata +//= type=exception +//# If the S3EC does not support decoding the S3 Server's "double encoding" then it MUST return the content metadata untouched. + +//= ../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 MUST NOT support providing a custom Instruction File suffix on ordinary writes; custom suffixes MUST only be used during re-encryption. + +//= ../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. + +//= ../specification/s3-encryption/data-format/metadata-strategy.md#v3-instruction-files +//= type=exception +//# - The V3 message format MUST store the mapkey "x-amz-m" and its value (when present in the content metadata) in the Instruction File. diff --git a/test-server/php-v3-server/compliance_exceptions/content-metadata.txt b/test-server/php-v3-server/compliance_exceptions/content-metadata.txt new file mode 100644 index 00000000..6053a0a6 --- /dev/null +++ b/test-server/php-v3-server/compliance_exceptions/content-metadata.txt @@ -0,0 +1,50 @@ +// +// The PHP V3 implementation is missing the following features: +// +// - Instruction file fallback when object doesn't match V1/V2/V3 formats +// - S3 Server "double encoding" scheme support +// - Writing raw keyring formats (RSA, AES) + +//= ../specification/s3-encryption/data-format/content-metadata.md#content-metadata-mapkeys +//= type=exception +//# - The mapkey "x-amz-key" MUST be present for V1 format objects. + +//= ../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. + +//= ../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#v3-only +//= type=exception +//# - 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 +//= type=exception +//# - The wrapping algorithm value "22" MUST be translated to RSA-OAEP-SHA1 upon retrieval, and vice versa on write. + +//= ../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. + +//= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status +//= type=exception +//# - If the metadata contains "x-amz-iv" and "x-amz-key" then the object MUST be considered as an S3EC-encrypted object using the V1 format. + +//= ../specification/s3-encryption/data-format/content-metadata.md#determining-s3ec-object-status +//= type=exception +//# If the object matches none of the V1/V2/V3 formats, the S3EC MUST attempt to get the instruction file. + +//= ../specification/s3-encryption/data-format/content-metadata.md#v3-only +//= type=exception +//# The Material Description MUST be used for wrapping algorithms `AES/GCM` (`02`) and `RSA-OAEP-SHA1` (`22`). + +//= ../specification/s3-encryption/data-format/content-metadata.md#v3-only +//= type=exception +//# If the mapkey is not present, the default Material Description value MUST be set to an empty map (`{}`). diff --git a/test-server/php-v3-server/compliance_exceptions/decryption.txt b/test-server/php-v3-server/compliance_exceptions/decryption.txt new file mode 100644 index 00000000..df86d896 --- /dev/null +++ b/test-server/php-v3-server/compliance_exceptions/decryption.txt @@ -0,0 +1,25 @@ +// +// The PHP V3 implementation is missing the following features: +// +// - Support for "range" parameter on GetObject for partial downloads and decryption +// + +//= ../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. + +//= ../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. + +//= ../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. + +//= ../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. + +//= ../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. diff --git a/test-server/php-v3-server/compliance_exceptions/encryption.txt b/test-server/php-v3-server/compliance_exceptions/encryption.txt new file mode 100644 index 00000000..5ae44c91 --- /dev/null +++ b/test-server/php-v3-server/compliance_exceptions/encryption.txt @@ -0,0 +1,26 @@ +// +// The PHP V3 implementation is missing the following features: +// +// - Support for "range" parameter on GetObject for partial downloads and decryption +// +// The PHP V3 implementation has an extra "feature". +// NOTE that using this feature will cause the message to be unable to be decrypted by other language implementations. + +// - Support for AAD during content encryption +// + +//= ../specification/s3-encryption/encryption.md#alg-aes-256-ctr-iv16-tag16-no-kdf +//= type=exception +//# Attempts to encrypt using AES-CTR MUST fail. + +//= ../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. + +//= ../specification/s3-encryption/encryption.md#alg-aes-256-gcm-iv12-tag16-no-kdf +//= type=exception +//# The client MUST NOT provide any AAD when encrypting with ALG_AES_256_GCM_IV12_TAG16_NO_KDF. + +//= ../specification/s3-encryption/encryption.md#cipher-initialization +//= type=exception +//# The client SHOULD validate that the generated IV or Message ID is not zeros. diff --git a/test-server/php-v3-server/composer.json b/test-server/php-v3-server/composer.json new file mode 100644 index 00000000..32c2b00c --- /dev/null +++ b/test-server/php-v3-server/composer.json @@ -0,0 +1,36 @@ +{ + "name": "aws/s3ec-php-v3-test-server", + "description": "PHP v3 implementation of the S3EC Test Server framework", + "type": "project", + "license": "Apache-2.0", + "repositories": [ + { + "type": "path", + "url": "./local-php-sdk", + "options": { + "symlink": true + } + } + ], + "require": { + "php": ">=7.4", + "aws/aws-sdk-php": "@dev", + "ramsey/uuid": "^4.9" + }, + "autoload": { + "psr-4": { + "S3EC\\PhpV2Server\\": "src/" + } + }, + "scripts": { + "start": [ + "php -S 0.0.0.0:8093 src/index.php" + ] + }, + "config": { + "optimize-autoloader": true, + "platform": { + "php": "8.1" + } + } +} \ No newline at end of file diff --git a/test-server/php-v3-server/local-php-sdk b/test-server/php-v3-server/local-php-sdk new file mode 160000 index 00000000..f53d8fc6 --- /dev/null +++ b/test-server/php-v3-server/local-php-sdk @@ -0,0 +1 @@ +Subproject commit f53d8fc6cdbc1e64e7d14e72d1e315d05003b2b4 diff --git a/test-server/php-v3-server/src/client.php b/test-server/php-v3-server/src/client.php new file mode 100644 index 00000000..f57c643a --- /dev/null +++ b/test-server/php-v3-server/src/client.php @@ -0,0 +1,77 @@ +toString(); + $kmsKeyId = $keyMaterial["kmsKeyId"] ?? null; + $commitmentPolicy = $configData['commitmentPolicy'] ?? "REQUIRE_ENCRYPT_REQUIRE_DECRYPT"; + $instFileConfig = $configData['instructionFileConfig'] ?? null; + $instFilePut = false; + if ($instFileConfig != null) { + $instFilePut = $instFileConfig['enableInstructionFilePutObject'] ?? false; + } + + + if (empty($configData)) { + return GenericServerError("Invalid config in request body", 400); + } + if (is_null($keyMaterial) || is_null($kmsKeyId)) { + return GenericServerError("Invalid keyMaterial in config", 400); + } + + // Store client configuration instead of objects (AWS objects can't be serialized) + $_SESSION['s3ecCache'][$clientId] = [ + 's3Config' => [ + 'region' => 'us-west-2', + 'version' => 'latest', + 'http' => [ + 'debug' => false, + 'verify' => true, + 'curl' => [ + CURLOPT_VERBOSE => false, + CURLOPT_NOPROGRESS => true + ] + ] + ], + 'kmsConfig' => [ + 'region' => 'us-west-2', + 'version' => 'latest', + 'http' => [ + 'debug' => false, + 'verify' => true, + 'curl' => [ + CURLOPT_VERBOSE => false, + CURLOPT_NOPROGRESS => true + ] + ] + ], + 'kmsKeyId' => $kmsKeyId, + 'legacy' => $legacyAlgorithms, + 'commitmentPolicy' => $commitmentPolicy, + 'instFilePut' => $instFilePut, + 'created' => time() + ]; + + // Auto-update cookies.txt with current session ID so tests can access cached clients + writeSessionIdToCookiesFile(session_id()); + + header("Content-Type: application/json"); + return json_encode([ + 'clientId' => $clientId, + ]); +} diff --git a/test-server/php-v3-server/src/errors.php b/test-server/php-v3-server/src/errors.php new file mode 100644 index 00000000..2b59861d --- /dev/null +++ b/test-server/php-v3-server/src/errors.php @@ -0,0 +1,42 @@ + 'GenericServerError', + 'message' => $message + ]; + + return json_encode($errorResponse); +} + +/** + * Used for modeled errors, e.g. errors thrown by the S3EC + * Tests SHOULD expect this error in negative tests. + * + * @param string $message The error message to include in the response + * @return string JSON-encoded error response + */ +function S3EncryptionClientError($message) +{ + http_response_code(500); + header('Content-Type: application/json'); + + $errorResponse = [ + "__type" => "software.amazon.encryption.s3#S3EncryptionClientError", + 'message' => $message + ]; + + return json_encode($errorResponse); +} diff --git a/test-server/php-v3-server/src/get_object.php b/test-server/php-v3-server/src/get_object.php new file mode 100644 index 00000000..fbd42f7a --- /dev/null +++ b/test-server/php-v3-server/src/get_object.php @@ -0,0 +1,108 @@ + $legacy, + '@MaterialsProvider' => $materialProvider, + '@KmsEncryptionContext' => $encryptionContext, + '@CommitmentPolicy' => $commitmentPolicy, + 'Bucket' => $bucket, + 'Key' => $key, + ]; + + // Add custom instruction file suffix if provided + if (!is_null($instructionFileSuffix) && !empty($instructionFileSuffix)) { + $getObjectParams['@InstructionFileSuffix'] = $instructionFileSuffix; + } + + $result = $s3ec->getObject($getObjectParams); + + // Capture and discard any unwanted output from AWS SDK + $unwantedOutput = ob_get_clean(); + if (!empty($unwantedOutput)) { + error_log("AWS SDK produced unexpected output: " . strlen($unwantedOutput) . " bytes"); + } + + $body = $result['Body']->getContents(); + $formattedMetadata = formatMetadataForResponse($result["Metadata"]); + + // Now set headers safely + header("Content-Metadata: " . $formattedMetadata); + header("Content-Type: application/octet-stream"); + header("Content-Length: " . strlen($body)); + return $body; + } catch (InvalidArgumentException $e) { + // Clean up output buffer if still active + if (ob_get_level()) { + ob_end_clean(); + } + return GenericServerError("Invalid argument: " . $e->getMessage(), 400); + } catch (Exception $e) { + // Clean up output buffer if still active + if (ob_get_level()) { + ob_end_clean(); + } + if (strpos($e->getMessage(), "@SecurityProfile=V3") !== false) { + return S3EncryptionClientError($e->getMessage()); + } elseif (strpos($e->getMessage(), "Provided encryption context does not match information retrieved from S3") !== false) { + return S3EncryptionClientError($e->getMessage()); + } elseif (strpos($e->getMessage(), "Message is encrypted with a non commiting algorithm but commitment policy is set to REQUIRE_ENCRYPT_REQUIRE_DECRYPT. Select a valid commitment policy to decrypt this object.") !== false) { + return S3EncryptionClientError($e->getMessage()); + } elseif (strpos($e->getMessage(), "One or more reserved keys found in Instruction file when they should not be present.") !== false) { + return S3EncryptionClientError($e->getMessage()); + } elseif (strpos($e->getMessage(), "Expected a V3 envelope but was unable to constuct one.") !== false) { + return S3EncryptionClientError($e->getMessage()); + } elseif (strpos($e->getMessage(), "Malformed metadata envelope.") !== false) { + return S3EncryptionClientError($e->getMessage()); + } else { + error_log("This is the error: " . $e->getMessage()); + return GenericServerError("Server argument: " . $e->getMessage(), 500); + } + } +} diff --git a/test-server/php-v3-server/src/index.php b/test-server/php-v3-server/src/index.php new file mode 100644 index 00000000..f5f5cdb5 --- /dev/null +++ b/test-server/php-v3-server/src/index.php @@ -0,0 +1,295 @@ += 7 && $parts[5] === 'PHPSESSID') { + error_log("Found session ID in cookies.txt: " . $parts[6]); + return $parts[6]; // Return the session ID value + } + } + + error_log("No PHPSESSID found in cookies.txt file"); + return null; +} + +// Function to write session ID to cookies.txt file +function writeSessionIdToCookiesFile($sessionId) +{ + $cookiesFile = __DIR__ . '/../cookies.txt'; + + // Create Netscape cookie format entry + $cookieLine = "localhost\tFALSE\t/\tFALSE\t0\tPHPSESSID\t$sessionId"; + + // Write header and cookie entry + $content = "# Netscape HTTP Cookie File\n"; + $content .= "# https://curl.se/docs/http-cookies.html\n"; + $content .= "# This file was generated by libcurl! Edit at your own risk.\n\n"; + $content .= $cookieLine . "\n"; + + $result = file_put_contents($cookiesFile, $content); + + if ($result === false) { + error_log("Failed to write session ID to cookies.txt file: $cookiesFile"); + return false; + } + + error_log("Successfully wrote session ID to cookies.txt: $sessionId"); + return true; +} + +set_time_limit(600); +// Start session to persist cache across requests +// First try to use session ID from cookies.txt if available +$sessionId = getSessionIdFromCookiesFile(); +if ($sessionId) { + session_id($sessionId); +} +session_start(); + +// Initialize session cache if it doesn't exist +if (!isset($_SESSION['s3ecCache'])) { + $_SESSION['s3ecCache'] = []; +} + +// Simple router class +class SimpleRouter +{ + private $routes = []; + + public function addRoute($method, $path, $handler) + { + $this->routes[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'handler' => $handler + ]; + } + + public function handleRequest() + { + $method = $_SERVER['REQUEST_METHOD']; + $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + + foreach ($this->routes as $route) { + if ($route['method'] === $method) { + $params = $this->matchPathWithParams($route['path'], $path); + if ($params !== false) { + return call_user_func($route['handler'], $params); + } + } + } + + // Default 404 response + http_response_code(404); + return json_encode(['error' => 'Not Found']); + } + + private function matchPathWithParams($routePath, $requestPath) + { + // Handle exact matches first (for routes without parameters) + if ($routePath === $requestPath) { + return []; + } + + // Convert route path like '/object/{bucket}/{key}' to regex + $pattern = preg_replace('/\{([^}]+)\}/', '([^/]+)', $routePath); + $pattern = '/^' . str_replace('/', '\/', $pattern) . '$/'; + + if (preg_match($pattern, $requestPath, $matches)) { + array_shift($matches); // Remove full match + + // Extract parameter names + preg_match_all('/\{([^}]+)\}/', $routePath, $paramNames); + $params = []; + + foreach ($paramNames[1] as $index => $paramName) { + $params[$paramName] = $matches[$index] ?? null; + } + + return $params; + } + + return false; + } +} + +// Helper function to get cached client by ID +function getCachedClient($clientId) +{ + if (!isset($_SESSION['s3ecCache'][$clientId])) { + return null; + } + + $config = $_SESSION['s3ecCache'][$clientId]; + + // Recreate the AWS clients from stored configuration + $s3Client = new S3Client($config['s3Config']); + $encryptionClient = new S3EncryptionClientV3($s3Client); + + $kmsClient = new KmsClient($config['kmsConfig']); + $materialsProvider = new KmsMaterialsProviderV3($kmsClient, $config['kmsKeyId']); + + return [ + 's3Client' => $s3Client, + 'encryptionClient' => $encryptionClient, + 'materialsProvider' => $materialsProvider, + 'config' => $config + ]; +} + +function createDefaultClientTuple(): array +{ + $s3Client = new S3Client([ + 'region' => 'us-west-2', + 'version' => 'latest', + 'http' => [ + 'debug' => false, + 'verify' => true, + 'curl' => [ + CURLOPT_VERBOSE => false, + CURLOPT_NOPROGRESS => true + ] + ] + ]); + $encryptionClient = new S3EncryptionClientV3($s3Client); + + $kmsClient = new KmsClient([ + 'region' => 'us-west-2', + 'version' => 'latest', + 'http' => [ + 'debug' => false, + 'verify' => true, + 'curl' => [ + CURLOPT_VERBOSE => false, + CURLOPT_NOPROGRESS => true + ] + ] + ]); + $materialsProvider = new KmsMaterialsProviderV3($kmsClient, 'arn:aws:kms:us-west-2:370957321024:alias/S3EC-Test-Server-Github-KMS-Key'); + + return [ + 'encryptionClient' => $encryptionClient, + 'materialsProvider' => $materialsProvider + ]; +} + +function metadataStringToMap($metadata): array +{ + $md = []; + + if (empty($metadata)) { + return $md; + } + + $mdList = explode(',', $metadata); + + foreach ($mdList as $entry) { + $parts = explode(']:[', $entry); + + if (count($parts) === 2) { + $key = substr($parts[0], 1); + $value = substr($parts[1], 0, -1); + $md[$key] = $value; + } else { + throw new InvalidArgumentException("Malformed metadata list entry: " . $entry); + } + } + + return $md; +} +function formatMetadataForResponse($metadata) +{ + $metadataList = []; + // Handle different metadata input types + if (is_array($metadata)) { + // If it's an associative array (like Python dict) + foreach ($metadata as $key => $value) { + $metadataList[] = $key . '=' . $value; + } + } elseif (is_string($metadata) && !empty($metadata)) { + // If it's already a string, assume it's in the correct format + return $metadata; + } + + // Convert array to comma-separated string + return implode(',', $metadataList); +} + +// Initialize router +$router = new SimpleRouter(); + +// Add basic routes +$router->addRoute('GET', '/', function () { + return json_encode([ + 'service' => 'S3EC PHP v2 Test Server', + 'status' => 'running', + 'port' => 8087, + 'endpoints' => [ + 'GET /' => 'Server status', + 'POST /client' => 'Create an S3EncryptionClient and cache it.', + 'GET /object/{bucket}/{key}' => 'Handle GET requests to /object/{bucket}/{key} by using the S3EncryptionClient to make a GetObject request to S3.', + 'PUT /object/{bucket}/{key}' => 'Handle PUT requests to /object/{bucket}/{key} by using the S3EncryptionClient to make a PutObject request to S3.', + ] + ]); +}); + +$router->addRoute('GET', '/cache', function () { + return json_encode([ + 'sessionId' => session_id(), + 'sessionStatus' => session_status(), + 'totalCachedClients' => count($_SESSION['s3ecCache'] ?? []), + 'allClientIds' => array_keys($_SESSION['s3ecCache'] ?? []), + 'cacheDetails' => $_SESSION['s3ecCache'] ?? [] + ]); +}); + +$router->addRoute('GET', '/object/{bucket}/{key}', function ($params) { + return handleGetObject($params); +}); + +$router->addRoute('PUT', '/object/{bucket}/{key}', function ($params) { + return handlePutObject($params); +}); + +$router->addRoute('POST', '/client', function () { + return handleCreateClient(); +}); + +// Handle the request and output response +$result = $router->handleRequest(); +if ($result !== false) { + echo $result; +} diff --git a/test-server/php-v3-server/src/put_object.php b/test-server/php-v3-server/src/put_object.php new file mode 100644 index 00000000..2f882b1e --- /dev/null +++ b/test-server/php-v3-server/src/put_object.php @@ -0,0 +1,82 @@ + 'gcm', + 'KeySize' => 256, + ]; + $legacyConfig = $s3ecClientTuple["legacy"] ?? false; + $legacy = null; + if ($legacyConfig === false) { + $legacy = "V2"; + } else { + $legacy = "V2_AND_LEGACY"; + } + $commitmentPolicy = $s3ecClientTuple['config']['commitmentPolicy']; + $strategy = $s3ecClientTuple["config"]["instFilePut"] ? + new InstructionFileMetadataStrategy($s3Client) : + new HeadersMetadataStrategy(); + + try { + $result = $s3ec->putObject([ + '@SecurityProfile' => $legacy, + '@MaterialsProvider' => $materialProvider, + '@CommitmentPolicy' => $commitmentPolicy, + '@KmsEncryptionContext' => $encryptionContext, + '@MetadataStrategy' => $strategy, + '@CipherOptions' => $cipherOptions, + 'Bucket' => $bucket, + 'Key' => $key, + 'Body' => $rawBody, + ]); + + header("Content-Type: application/json"); + return json_encode([ + "bucket" => $bucket, + "key" => $key, + // php for some reason blows java's heap if we pass the metadata + // "metadata" => $encryptionContext + ]); + + } catch (InvalidArgumentException $e) { + return S3EncryptionClientError("Invalid arguement: " . $e->getMessage()); + } catch (Exception $e) { + return GenericServerError("Server error: " . $e->getMessage()); + } +} diff --git a/test-server/python-v3-server/.duvet/.gitignore b/test-server/python-v3-server/.duvet/.gitignore new file mode 100644 index 00000000..93956e36 --- /dev/null +++ b/test-server/python-v3-server/.duvet/.gitignore @@ -0,0 +1,3 @@ +reports/ +requirements/ +specification/ \ No newline at end of file diff --git a/test-server/python-v3-server/.duvet/config.toml b/test-server/python-v3-server/.duvet/config.toml new file mode 100644 index 00000000..09dbe6d3 --- /dev/null +++ b/test-server/python-v3-server/.duvet/config.toml @@ -0,0 +1,22 @@ +'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json" + +[[source]] +pattern = "**/*.py" +comment-style = { meta = "##=", content = "##%" } + +# Include required specifications here +[[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/key-derivation.md" + +[report.html] +enabled = true + +# Enable snapshots to prevent requirement coverage regressions +[report.snapshot] +enabled = false diff --git a/test-server/python-server/.gitignore b/test-server/python-v3-server/.gitignore similarity index 100% rename from test-server/python-server/.gitignore rename to test-server/python-v3-server/.gitignore diff --git a/test-server/python-v3-server/Makefile b/test-server/python-v3-server/Makefile new file mode 100644 index 00000000..930c950c --- /dev/null +++ b/test-server/python-v3-server/Makefile @@ -0,0 +1,42 @@ +# Makefile for S3 Encryption Client Testing + +.PHONY: build-server start-server stop-server wait-for-server + +PID_FILE := server.pid +PORT := 8081 + +build-server: + @echo "Building Python V3 server..." + python -m venv .venv + .venv/bin/python -m ensurepip + .venv/bin/python -m pip install -e . + .venv/bin/python -m pip install -e ../.. + +start-server: + @echo "Starting Python V3 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" \ + .venv/bin/python src/main.py > server.log 2>&1 & echo $$! > $(PID_FILE) + @echo "Python 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/python-server/README.md b/test-server/python-v3-server/README.md similarity index 100% rename from test-server/python-server/README.md rename to test-server/python-v3-server/README.md diff --git a/test-server/python-server/poetry.lock b/test-server/python-v3-server/poetry.lock similarity index 100% rename from test-server/python-server/poetry.lock rename to test-server/python-v3-server/poetry.lock diff --git a/test-server/python-server/pyproject.toml b/test-server/python-v3-server/pyproject.toml similarity index 100% rename from test-server/python-server/pyproject.toml rename to test-server/python-v3-server/pyproject.toml diff --git a/test-server/python-server/src/__init__.py b/test-server/python-v3-server/src/__init__.py similarity index 100% rename from test-server/python-server/src/__init__.py rename to test-server/python-v3-server/src/__init__.py diff --git a/test-server/python-server/src/main.py b/test-server/python-v3-server/src/main.py similarity index 100% rename from test-server/python-server/src/main.py rename to test-server/python-v3-server/src/main.py diff --git a/test-server/python-server/tests/__init__.py b/test-server/python-v3-server/tests/__init__.py similarity index 100% rename from test-server/python-server/tests/__init__.py rename to test-server/python-v3-server/tests/__init__.py diff --git a/test-server/ruby-v2-server/.duvet/.gitignore b/test-server/ruby-v2-server/.duvet/.gitignore new file mode 100644 index 00000000..93956e36 --- /dev/null +++ b/test-server/ruby-v2-server/.duvet/.gitignore @@ -0,0 +1,3 @@ +reports/ +requirements/ +specification/ \ No newline at end of file diff --git a/test-server/ruby-v2-server/.duvet/config.toml b/test-server/ruby-v2-server/.duvet/config.toml new file mode 100644 index 00000000..7a34c0ff --- /dev/null +++ b/test-server/ruby-v2-server/.duvet/config.toml @@ -0,0 +1,33 @@ +'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json" + +[[source]] +pattern = "local-ruby-sdk/gems/aws-sdk-s3/lib/**/*.rb" +comment-style = { meta = "##=", content = "##%" } + +[[source]] +pattern = "local-ruby-sdk/gems/aws-sdk-s3/spec/**/*.rb" +comment-style = { meta = "##=", content = "##%" } + +# 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/ruby-v2-server/.gitignore b/test-server/ruby-v2-server/.gitignore new file mode 100644 index 00000000..d20a29c0 --- /dev/null +++ b/test-server/ruby-v2-server/.gitignore @@ -0,0 +1,2 @@ +vendor +server.pid \ No newline at end of file diff --git a/test-server/ruby-v2-server/Gemfile b/test-server/ruby-v2-server/Gemfile new file mode 100644 index 00000000..2759a87b --- /dev/null +++ b/test-server/ruby-v2-server/Gemfile @@ -0,0 +1,15 @@ +source 'https://rubygems.org' + +ruby '~> 3.0' + +gem 'sinatra', '~> 3.0' +gem 'puma', '~> 6.0' +gem 'aws-sdk-s3', path: 'local-ruby-sdk/gems/aws-sdk-s3' +gem 'aws-sdk-kms', path: 'local-ruby-sdk/gems/aws-sdk-kms' +gem 'json', '~> 2.0' +gem 'concurrent-ruby', '~> 1.0' +gem 'nokogiri', '~> 1.13' + +group :development do + gem 'rubocop', '~> 1.0' +end diff --git a/test-server/ruby-v2-server/Gemfile.lock b/test-server/ruby-v2-server/Gemfile.lock new file mode 100644 index 00000000..b9f08375 --- /dev/null +++ b/test-server/ruby-v2-server/Gemfile.lock @@ -0,0 +1,111 @@ +PATH + remote: local-ruby-sdk/gems/aws-sdk-kms + specs: + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) + +PATH + remote: local-ruby-sdk/gems/aws-sdk-s3 + specs: + aws-sdk-s3 (1.206.0) + aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + aws-eventstream (1.4.0) + aws-partitions (1.1180.0) + aws-sdk-core (3.239.2) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + base64 (0.3.0) + bigdecimal (3.3.1) + bigdecimal (3.3.1-java) + concurrent-ruby (1.3.5) + jmespath (1.6.2) + json (2.13.2) + json (2.13.2-java) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + mustermann (3.0.4) + ruby2_keywords (~> 0.0.1) + nio4r (2.7.4) + nio4r (2.7.4-java) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-java) + racc (~> 1.4) + parallel (1.27.0) + parser (3.3.9.0) + ast (~> 2.4.1) + racc + prism (1.5.1) + puma (6.6.1) + nio4r (~> 2.0) + puma (6.6.1-java) + nio4r (~> 2.0) + racc (1.8.1) + racc (1.8.1-java) + rack (2.2.17) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rainbow (3.1.1) + regexp_parser (2.11.3) + rubocop (1.80.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.46.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.46.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) + tilt (~> 2.0) + tilt (2.6.1) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + +PLATFORMS + arm64-darwin-24 + universal-java-21 + +DEPENDENCIES + aws-sdk-kms! + aws-sdk-s3! + concurrent-ruby (~> 1.0) + json (~> 2.0) + nokogiri (~> 1.13) + puma (~> 6.0) + rubocop (~> 1.0) + sinatra (~> 3.0) + +RUBY VERSION + ruby 3.4.5p51 + +BUNDLED WITH + 2.6.9 diff --git a/test-server/ruby-v2-server/Makefile b/test-server/ruby-v2-server/Makefile new file mode 100644 index 00000000..e0f938fc --- /dev/null +++ b/test-server/ruby-v2-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 := 8098 + +build-server: + @echo "Building Ruby V2 server..." + bundle install + +start-server: + @if [ -f $(PID_FILE) ]; then \ + echo "❌ Error: Server already running. Stop before starting."; \ + exit 1; \ + fi; + @echo "Starting Ruby V2 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" \ + PORT=$(PORT) bundle exec ruby app.rb > server.log 2>&1 & echo $$! > $(PID_FILE) + @echo "Ruby V2 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/ruby-v2-server/README.md b/test-server/ruby-v2-server/README.md new file mode 100644 index 00000000..4b3e5209 --- /dev/null +++ b/test-server/ruby-v2-server/README.md @@ -0,0 +1,73 @@ +# Ruby S3 Encryption Client Test Server + +This is a Ruby implementation of the S3 Encryption Client test server +that provides an invariant interface around the S3 Encryption Client v2. +It's designed to work alongside other implementations of test servers for cross-language compatibility testing. + +## Overview + +The server provides a REST API that wraps the AWS S3 Encryption Client v2, +allowing tests to verify that all language implementations behave consistently. + +## Endpoints + +- `POST /client` - Create a new S3 encryption client instance +- `PUT /object/{bucket}/{key}` - Encrypt and store an object +- `GET /object/{bucket}/{key}` - Retrieve and decrypt an object +- `GET /health` - Health check endpoint + +## Configuration + +The server runs on port **8086** by default. + +## Setup + +1. Install Ruby 3.x +2. Install dependencies: + + ```bash + cd test-server/ruby-v2-server + bundle install + ``` + +3. Set up AWS credentials (via AWS CLI, environment variables, or IAM roles) + +4. Start the server: + + ```bash + ruby app.rb + # or using Rack + bundle exec rackup -p 8086 + ``` + +## Usage + +The server is designed to be used by the Java test suite in `test-server/java-tests/`. +The tests will automatically discover and use this server for cross-language compatibility testing. + +### Environment Variables + +- `TEST_SERVER_KMS_KEY_ARN` - KMS key ARN for encryption (defaults to test key) +- `TEST_SERVER_S3_BUCKET` - S3 bucket for testing (defaults to test bucket) + +## Architecture + +- `app.rb` - Main Sinatra application +- `lib/client_manager.rb` - Manages S3 encryption client instances +- `lib/metadata_utils.rb` - Handles metadata serialization/deserialization +- `lib/error_handlers.rb` - Smithy-compliant error responses + +## Error Handling + +The server returns errors in the format expected by the Smithy model: + +- `GenericServerError` - Internal server errors +- `S3EncryptionClientError` - Errors from the S3 Encryption Client + +## Compatibility + +This server is compatible with: + +- S3 Encryption Client v2 +- Legacy v1 clients (when `enableLegacyWrappingAlgorithms` is true) +- Cross-language testing with other implementations diff --git a/test-server/ruby-v2-server/app.rb b/test-server/ruby-v2-server/app.rb new file mode 100644 index 00000000..96e55c8a --- /dev/null +++ b/test-server/ruby-v2-server/app.rb @@ -0,0 +1,241 @@ +require 'sinatra' +require 'json' +require_relative 'lib/client_manager' +require_relative 'lib/metadata_utils' +require_relative 'lib/error_handlers' +require_relative 'lib/logger' + +# See: https://github.com/ruby/openssl/issues/949 +Aws.use_bundled_cert! + +class S3ECRubyServer < Sinatra::Base + configure do + set :port, ENV['PORT'] || 8098 + set :bind, '0.0.0.0' + set :show_exceptions, false + set :raise_errors, false + end + + def initialize + super + @client_manager = ClientManager.new + S3ECLogger.info("S3EC_SERVER: Ruby V2 server initialized on port #{settings.port}") + end + + # Request logging middleware + before do + @request_id = S3ECLogger.generate_request_id + S3ECLogger.log_request(request.request_method, request.path_info, request.env, @request_id) + end + + # Response logging middleware + after do + S3ECLogger.log_response(response.status, @request_id) + end + + # Health check endpoint + get '/health' do + content_type :json + { status: 'OK', server: 'Ruby V2 S3EC Test Server', port: settings.port.to_i }.to_json + end + + # POST /client - Create S3 encryption client + post '/client' do + begin + S3ECLogger.debug("CLIENT_ENDPOINT [#{@request_id}]: Processing client creation request") + + # Parse request body + request_body = request.body.read + S3ECLogger.debug("CLIENT_ENDPOINT [#{@request_id}]: Request body size: #{request_body.length} bytes") + + parsed_data = JSON.parse(request_body) + config = parsed_data['config'] || {} + + S3ECLogger.debug("CLIENT_ENDPOINT [#{@request_id}]: Parsed config: #{config.inspect}") + + # Create client using client manager + client_id = @client_manager.create_client(config) + + S3ECLogger.info("CLIENT_ENDPOINT [#{@request_id}]: Successfully created client #{client_id}") + + # Return client ID + content_type :json + { clientId: client_id }.to_json + + rescue JSON::ParserError => e + S3ECLogger.log_error(e, { endpoint: '/client', operation: 'JSON parsing' }, @request_id) + ErrorHandlers.send_generic_server_error(self, "Invalid JSON in request body", 400) + rescue => e + S3ECLogger.log_error(e, { endpoint: '/client', operation: 'client creation', config: config }, @request_id) + ErrorHandlers.send_s3_encryption_client_error(self, "Failed to create client: #{e.message}") + end + end + + # PUT /object/{bucket}/{key} - Encrypt and put object + put '/object/:bucket/:key' do + bucket = params[:bucket] + key = params[:key] + client_id = request.env['HTTP_CLIENTID'] + + begin + S3ECLogger.debug("PUT_ENDPOINT [#{@request_id}]: Processing PUT request for s3://#{bucket}/#{key}") + + # Validate client ID + unless client_id + S3ECLogger.log_validation_error('ClientID', 'missing', @request_id) + ErrorHandlers.send_generic_server_error(self, "ClientID header is required", 400) + end + + # Get client from cache + client = @client_manager.get_client(client_id) + unless client + S3ECLogger.log_validation_error('ClientID', client_id, @request_id) + ErrorHandlers.send_generic_server_error(self, "No client found for ClientID: #{client_id}", 404) + end + + # Get request body + body = request.body.read + S3ECLogger.debug("PUT_ENDPOINT [#{@request_id}]: Request body size: #{body.length} bytes") + + # Parse metadata from header + metadata_header = request.env['HTTP_CONTENT_METADATA'] || '' + encryption_context = MetadataUtils.string_to_map(metadata_header) + S3ECLogger.log_metadata_processing('parse', metadata_header, encryption_context) + + # Prepare S3 put_object parameters + put_params = { + bucket: bucket, + key: key, + body: body + } + + # Add encryption context if present + put_params[:kms_encryption_context] = encryption_context unless encryption_context.empty? + + # Log S3 operation + S3ECLogger.log_s3_operation('put', bucket, key, encryption_context, "ClientID: #{client_id}, BodySize: #{body.length}") + + # Make the put_object request + response = client.put_object(put_params) + + S3ECLogger.info("PUT_ENDPOINT [#{@request_id}]: Successfully put object s3://#{bucket}/#{key}") + + # Prepare response metadata + response_metadata = MetadataUtils.map_to_array(encryption_context) + S3ECLogger.log_metadata_processing('response', encryption_context, response_metadata) + + # Return response matching Smithy model + content_type :json + { + bucket: bucket, + key: key, + metadata: response_metadata + }.to_json + + rescue Aws::S3::EncryptionV2::Errors::EncryptionError, Aws::S3::EncryptionV3::Errors::EncryptionError => e + S3ECLogger.log_error(e, { endpoint: '/put', error_category: 'EncryptionError' }, @request_id) + ErrorHandlers.send_s3_encryption_client_error(self, e.message) + rescue StandardError => e + # Handle generic server errors (return as GenericServerError) + S3ECLogger.log_error(e, { endpoint: '/put', error_category: 'generic_server' }, @request_id) + status_code = e.respond_to?(:code) ? e.code : 500 + ErrorHandlers.send_generic_server_error(self, e.message, status_code) + end + end + + # GET /object/{bucket}/{key} - Get and decrypt object + get '/object/:bucket/:key' do + bucket = params[:bucket] + key = params[:key] + client_id = request.env['HTTP_CLIENTID'] + + begin + S3ECLogger.debug("GET_ENDPOINT [#{@request_id}]: Processing GET request for s3://#{bucket}/#{key}") + + # Validate client ID + unless client_id + S3ECLogger.log_validation_error('ClientID', 'missing', @request_id) + ErrorHandlers.send_generic_server_error(self, "ClientID header is required", 400) + end + + # Get client from cache + client = @client_manager.get_client(client_id) + unless client + S3ECLogger.log_validation_error('ClientID', client_id, @request_id) + ErrorHandlers.send_generic_server_error(self, "No client found for ClientID: #{client_id}", 404) + end + + # Parse metadata from header + metadata_header = request.env['HTTP_CONTENT_METADATA'] || '' + encryption_context = MetadataUtils.string_to_map(metadata_header) + S3ECLogger.log_metadata_processing('parse', metadata_header, encryption_context) + + # Prepare S3 get_object parameters + get_params = { + bucket: bucket, + key: key + } + + # Add custom instruction file suffix if present + instruction_file_suffix = request.env['HTTP_INSTRUCTIONFILESUFFIX'] + if instruction_file_suffix && !instruction_file_suffix.empty? + get_params[:envelope_location] = :instruction_file + get_params[:instruction_file_suffix] = instruction_file_suffix + S3ECLogger.debug("GET_ENDPOINT [#{@request_id}]: Using custom instruction file suffix: #{instruction_file_suffix}") + elsif !encryption_context.empty? + get_params[:kms_encryption_context] = encryption_context + end + + # Log S3 operation + S3ECLogger.log_s3_operation('get', bucket, key, encryption_context, "ClientID: #{client_id}") + + # Make the get_object request + response = client.get_object(get_params) + + # Extract body and metadata + body = response.body.read + metadata = response.metadata || {} + + S3ECLogger.info("GET_ENDPOINT [#{@request_id}]: Successfully got object s3://#{bucket}/#{key}, BodySize: #{body.length}") + + # Set Content-Metadata header in response + metadata_str = MetadataUtils.map_to_string(metadata) + S3ECLogger.log_metadata_processing('response', metadata, metadata_str) + + headers['Content-Metadata'] = metadata_str unless metadata_str.empty? + + # Return the body as response + content_type 'application/octet-stream' + body + + rescue Aws::S3::EncryptionV2::Errors::DecryptionError, Aws::S3::EncryptionV3::Errors::DecryptionError => e + S3ECLogger.log_error(e, { endpoint: '/get', error_category: 'DecryptionError' }, @request_id) + ErrorHandlers.send_s3_encryption_client_error(self, e.message) + rescue StandardError => e + # Handle generic server errors (return as GenericServerError) + S3ECLogger.log_error(e, { endpoint: '/get', error_category: 'generic_server' }, @request_id) + status_code = e.respond_to?(:code) ? e.code : 500 + ErrorHandlers.send_generic_server_error(self, e.message, status_code) + end + end + + # Global error handler + error do + error = env['sinatra.error'] + context = { + endpoint: request.path_info, + method: request.request_method, + params: params, + error_type: 'global_error_handler' + } + + S3ECLogger.log_error(error, context, @request_id) + ErrorHandlers.send_generic_server_error(self, "Internal server error: #{error.message}") + end + + # Start server when run directly + if __FILE__ == $0 + S3ECLogger.info("S3EC_SERVER: Starting Ruby server on port #{settings.port}...") + run! + end +end diff --git a/test-server/ruby-v2-server/config.ru b/test-server/ruby-v2-server/config.ru new file mode 100644 index 00000000..99d3a689 --- /dev/null +++ b/test-server/ruby-v2-server/config.ru @@ -0,0 +1,3 @@ +require_relative 'app' + +run S3ECRubyServer diff --git a/test-server/ruby-v2-server/lib/client_manager.rb b/test-server/ruby-v2-server/lib/client_manager.rb new file mode 100644 index 00000000..3da62b45 --- /dev/null +++ b/test-server/ruby-v2-server/lib/client_manager.rb @@ -0,0 +1,109 @@ +require 'concurrent-ruby' +require 'securerandom' +require 'aws-sdk-s3' +require 'aws-sdk-kms' +require 'openssl' +require 'base64' +require_relative 'logger' + +# Manages S3 Encryption Client instances +class ClientManager + def initialize + @client_cache = Concurrent::Hash.new + @kms_client = Aws::KMS::Client.new(region: 'us-west-2') + S3ECLogger.info("CLIENT_MANAGER: Initialized with KMS client for us-west-2") + end + + # Create a new S3 encryption client and return its ID + def create_client(config) + # Extract all key material types + kms_key_id = config.dig('keyMaterial', 'kmsKeyId') + rsa_key_blob = config.dig('keyMaterial', 'rsaKey') + aes_key_blob = config.dig('keyMaterial', 'aesKey') + inst_file_put = config.dig('instructionFileConfig', 'enableInstructionFilePutObject') + + # Validate that only one key type is provided + key_count = [kms_key_id, rsa_key_blob, aes_key_blob].compact.count + raise 'KeyMaterial must contain exactly one non-null key type' unless key_count == 1 + + # Create S3 encryption client configuration + encryption_config = { + content_encryption_schema: :aes_gcm_no_padding, + envelope_location: inst_file_put ? :instruction_file : :metadata + } + + # Configure based on key type + if kms_key_id + encryption_config[:kms_key_id] = kms_key_id + encryption_config[:kms_client] = @kms_client + encryption_config[:key_wrap_schema] = :kms_context + elsif rsa_key_blob + # Parse RSA private key from PKCS8 format + key_bytes = Base64.decode64(rsa_key_blob) + rsa_key = OpenSSL::PKey::RSA.new(key_bytes) + encryption_config[:encryption_key] = rsa_key + encryption_config[:key_wrap_schema] = :rsa_oaep_sha1 + elsif aes_key_blob + # Extract AES key bytes + key_bytes = Base64.decode64(aes_key_blob) + encryption_config[:encryption_key] = key_bytes + encryption_config[:key_wrap_schema] = :aes_gcm + end + + # Apply legacy settings + encryption_config.tap do |hash| + if !config['enableLegacyWrappingAlgorithms'].nil? || !config['enableLegacyUnauthenticatedModes'].nil? + legacy_modes = config['enableLegacyWrappingAlgorithms'] || config['enableLegacyUnauthenticatedModes'] + # Set security profile based on legacy wrapping algorithms setting + hash[:security_profile] = legacy_modes ? :v2_and_legacy : :v2 + end + end + + # Create the S3 encryption client + # Create the S3 encryption client with retry configuration for throttling + s3_client = Aws::S3::Client.new( + region: 'us-west-2', + retry_mode: 'adaptive', + retry_limit: 5, + retry_backoff: lambda { |c| sleep(2 ** c.retries * 0.3 * rand) } + ) + encryption_client = Aws::S3::EncryptionV2::Client.new( + client: s3_client, + **encryption_config + ) + + # Generate client ID and store in cache + client_id = SecureRandom.uuid + @client_cache[client_id] = encryption_client + + # Log client creation + S3ECLogger.log_client_creation(config, client_id) + S3ECLogger.log_cache_stats(@client_cache.size) + + client_id + end + + # Get a client by ID + def get_client(client_id) + client = @client_cache[client_id] + if client + S3ECLogger.log_client_cache_hit(client_id) + else + S3ECLogger.log_client_cache_miss(client_id) + end + client + end + + # Remove a client from cache (optional cleanup) + def remove_client(client_id) + removed = @client_cache.delete(client_id) + S3ECLogger.info("CLIENT_CACHE: Removed client #{client_id} from cache") if removed + S3ECLogger.log_cache_stats(@client_cache.size) + removed + end + + # Get cache size (for debugging) + def cache_size + @client_cache.size + end +end diff --git a/test-server/ruby-v2-server/lib/error_handlers.rb b/test-server/ruby-v2-server/lib/error_handlers.rb new file mode 100644 index 00000000..234a9a55 --- /dev/null +++ b/test-server/ruby-v2-server/lib/error_handlers.rb @@ -0,0 +1,42 @@ +# Error handling utilities to match Smithy error types +require 'json' + +class ErrorHandlers + # Create a response that matches the GenericServerError type from the Smithy model + # Used for internal server errors + def self.create_generic_server_error(message, status_code = 500) + { + status: status_code, + headers: { 'Content-Type' => 'application/json' }, + body: { + '__type' => 'software.amazon.encryption.s3#GenericServerError', + 'message' => message + }.to_json + } + end + + # Create a response that matches the S3EncryptionClientError type from the Smithy model + # Used for errors thrown by the S3 Encryption Client + def self.create_s3_encryption_client_error(message, status_code = 500) + { + status: status_code, + headers: { 'Content-Type' => 'application/json' }, + body: { + '__type' => 'software.amazon.encryption.s3#S3EncryptionClientError', + 'message' => message + }.to_json + } + end + + # Helper method to send error response in Sinatra + def self.send_generic_server_error(app, message, status_code = 500) + error_response = create_generic_server_error(message, status_code) + app.halt error_response[:status], error_response[:headers], error_response[:body] + end + + # Helper method to send S3EC error response in Sinatra + def self.send_s3_encryption_client_error(app, message, status_code = 500) + error_response = create_s3_encryption_client_error(message, status_code) + app.halt error_response[:status], error_response[:headers], error_response[:body] + end +end diff --git a/test-server/ruby-v2-server/lib/logger.rb b/test-server/ruby-v2-server/lib/logger.rb new file mode 100644 index 00000000..3e820c7f --- /dev/null +++ b/test-server/ruby-v2-server/lib/logger.rb @@ -0,0 +1,105 @@ +require 'logger' +require 'securerandom' + +# Centralized logging utility for the S3EC Ruby server +class S3ECLogger + def self.instance + @instance ||= new + end + + def initialize + @logger = Logger.new(STDOUT) + @logger.level = ENV['LOG_LEVEL'] ? Logger.const_get(ENV['LOG_LEVEL'].upcase) : Logger::INFO + @logger.formatter = proc do |severity, datetime, progname, msg| + "[RUBY TRANSITIONAL #{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n" + end + end + + # Generate a unique request ID for correlation + def self.generate_request_id + SecureRandom.hex(8) + end + + # Request/Response logging + def self.log_request(method, path, headers = {}, request_id = nil) + client_id = headers['HTTP_CLIENTID'] || headers['ClientID'] || 'none' + content_metadata = headers['HTTP_CONTENT_METADATA'] || headers['Content-Metadata'] || 'none' + + instance.logger.info("REQUEST [#{request_id}] #{method} #{path} | ClientID: #{client_id} | Metadata: #{content_metadata}") + end + + def self.log_response(status, request_id = nil, additional_info = "") + info_str = additional_info.empty? ? "" : " | #{additional_info}" + instance.logger.info("RESPONSE [#{request_id}] Status: #{status}#{info_str}") + end + + # Operation-level logging + def self.log_client_creation(config, client_id) + kms_key = config.dig('keyMaterial', 'kmsKeyId') || 'unknown' + legacy_enabled = config['enableLegacyWrappingAlgorithms'] || false + instance.logger.info("CLIENT_CREATION: Created S3EC client #{client_id} | KMS Key: #{kms_key} | Legacy: #{legacy_enabled}") + end + + def self.log_client_cache_hit(client_id) + instance.logger.debug("CACHE_HIT: Found client #{client_id} in cache") + end + + def self.log_client_cache_miss(client_id) + instance.logger.warn("CACHE_MISS: Client #{client_id} not found in cache") + end + + def self.log_cache_stats(cache_size) + instance.logger.debug("CACHE_STATS: Current client cache size: #{cache_size}") + end + + def self.log_s3_operation(operation, bucket, key, encryption_context = {}, additional_info = "") + enc_ctx_str = encryption_context.empty? ? "none" : encryption_context.inspect + info_str = additional_info.empty? ? "" : " | #{additional_info}" + instance.logger.info("S3_OPERATION: #{operation.upcase} s3://#{bucket}/#{key} | EncCtx: #{enc_ctx_str}#{info_str}") + end + + def self.log_metadata_processing(operation, input, output) + instance.logger.debug("METADATA_#{operation.upcase}: Input: #{input.inspect} | Output: #{output.inspect}") + end + + # Enhanced error logging + def self.log_error(error, context = {}, request_id = nil) + error_context = context.empty? ? "" : " | Context: #{context.inspect}" + instance.logger.error("ERROR [#{request_id}] #{error.class}: #{error.message}#{error_context}") + + if error.backtrace && instance.debug? + instance.logger.debug("ERROR_BACKTRACE [#{request_id}]:\n#{error.backtrace.join("\n")}") + end + end + + def self.log_validation_error(field, value, request_id = nil) + instance.logger.warn("VALIDATION_ERROR [#{request_id}] Invalid #{field}: #{value}") + end + + def self.log_aws_error(error, operation, request_id = nil) + instance.logger.error("AWS_ERROR [#{request_id}] #{operation} failed: #{error.class} - #{error.message}") + end + + # Standard logging methods + def self.debug(message) + instance.logger.debug(message) + end + + def self.info(message) + instance.logger.info(message) + end + + def self.warn(message) + instance.logger.warn(message) + end + + def self.error(message) + instance.logger.error(message) + end + + attr_reader :logger + + def debug? + @logger.debug? + end +end diff --git a/test-server/ruby-v2-server/lib/metadata_utils.rb b/test-server/ruby-v2-server/lib/metadata_utils.rb new file mode 100644 index 00000000..72015fcc --- /dev/null +++ b/test-server/ruby-v2-server/lib/metadata_utils.rb @@ -0,0 +1,50 @@ +# Utility class for handling metadata serialization/deserialization +# Matches the format used by Java and Python servers: [key]:[value],[key2]:[value2] +class MetadataUtils + # Convert metadata string to hash + # Input: "[user-defined-enc-ctx-key]:[user-defined-enc-ctx-value],[user-defined-enc-ctx-key-2]:[user-defined-enc-ctx-value-2]" + # Output: {"user-defined-enc-ctx-key" => "user-defined-enc-ctx-value", "user-defined-enc-ctx-key-2" => "user-defined-enc-ctx-value-2"} + def self.string_to_map(metadata_string) + return {} if metadata_string.nil? || metadata_string.empty? + + metadata = {} + entries = metadata_string.split(',') + + entries.each do |entry| + # Split on "]:[" to separate key and value + parts = entry.split(']:[') + if parts.length == 2 + # Remove remaining brackets from start and end + key = parts[0].delete_prefix("[") # Remove first character '[' + value = parts[1].delete_suffix("]") # Remove last character ']' + metadata[key] = value + else + raise "Malformed metadata list entry: #{entry}" + end + end + + metadata + end + + # Convert hash to metadata string + # Input: {"user-defined-enc-ctx-key" => "user-defined-enc-ctx-value", "user-defined-enc-ctx-key-2" => "user-defined-enc-ctx-value-2"} + # Output: "[user-defined-enc-ctx-key]:[user-defined-enc-ctx-value],[user-defined-enc-ctx-key-2]:[user-defined-enc-ctx-value-2]" + def self.map_to_string(metadata_hash) + return '' if metadata_hash.nil? || metadata_hash.empty? + + entries = metadata_hash.map do |key, value| + "[#{key}]:[#{value}]" + end + + entries.join(',') + end + + # Convert metadata hash to array format (for JSON responses) + def self.map_to_array(metadata_hash) + return [] if metadata_hash.nil? || metadata_hash.empty? + + metadata_hash.map do |key, value| + "[#{key}]:[#{value}]" + end + end +end diff --git a/test-server/ruby-v2-server/local-ruby-sdk b/test-server/ruby-v2-server/local-ruby-sdk new file mode 160000 index 00000000..93985e94 --- /dev/null +++ b/test-server/ruby-v2-server/local-ruby-sdk @@ -0,0 +1 @@ +Subproject commit 93985e94bbe8345cc7d615d1cdbcd7516ac16bcd diff --git a/test-server/ruby-v3-server/.bundle/config b/test-server/ruby-v3-server/.bundle/config new file mode 100644 index 00000000..23692288 --- /dev/null +++ b/test-server/ruby-v3-server/.bundle/config @@ -0,0 +1,2 @@ +--- +BUNDLE_PATH: "vendor/bundle" diff --git a/test-server/ruby-v3-server/.duvet/.gitignore b/test-server/ruby-v3-server/.duvet/.gitignore new file mode 100644 index 00000000..93956e36 --- /dev/null +++ b/test-server/ruby-v3-server/.duvet/.gitignore @@ -0,0 +1,3 @@ +reports/ +requirements/ +specification/ \ No newline at end of file diff --git a/test-server/ruby-v3-server/.duvet/config.toml b/test-server/ruby-v3-server/.duvet/config.toml new file mode 100644 index 00000000..7a34c0ff --- /dev/null +++ b/test-server/ruby-v3-server/.duvet/config.toml @@ -0,0 +1,33 @@ +'$schema' = "https://awslabs.github.io/duvet/config/v0.4.0.json" + +[[source]] +pattern = "local-ruby-sdk/gems/aws-sdk-s3/lib/**/*.rb" +comment-style = { meta = "##=", content = "##%" } + +[[source]] +pattern = "local-ruby-sdk/gems/aws-sdk-s3/spec/**/*.rb" +comment-style = { meta = "##=", content = "##%" } + +# 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/ruby-v3-server/.gitignore b/test-server/ruby-v3-server/.gitignore new file mode 100644 index 00000000..d20a29c0 --- /dev/null +++ b/test-server/ruby-v3-server/.gitignore @@ -0,0 +1,2 @@ +vendor +server.pid \ No newline at end of file diff --git a/test-server/ruby-v3-server/Gemfile b/test-server/ruby-v3-server/Gemfile new file mode 100644 index 00000000..2759a87b --- /dev/null +++ b/test-server/ruby-v3-server/Gemfile @@ -0,0 +1,15 @@ +source 'https://rubygems.org' + +ruby '~> 3.0' + +gem 'sinatra', '~> 3.0' +gem 'puma', '~> 6.0' +gem 'aws-sdk-s3', path: 'local-ruby-sdk/gems/aws-sdk-s3' +gem 'aws-sdk-kms', path: 'local-ruby-sdk/gems/aws-sdk-kms' +gem 'json', '~> 2.0' +gem 'concurrent-ruby', '~> 1.0' +gem 'nokogiri', '~> 1.13' + +group :development do + gem 'rubocop', '~> 1.0' +end diff --git a/test-server/ruby-v3-server/Gemfile.lock b/test-server/ruby-v3-server/Gemfile.lock new file mode 100644 index 00000000..b9f08375 --- /dev/null +++ b/test-server/ruby-v3-server/Gemfile.lock @@ -0,0 +1,111 @@ +PATH + remote: local-ruby-sdk/gems/aws-sdk-kms + specs: + aws-sdk-kms (1.118.0) + aws-sdk-core (~> 3, >= 3.239.1) + aws-sigv4 (~> 1.5) + +PATH + remote: local-ruby-sdk/gems/aws-sdk-s3 + specs: + aws-sdk-s3 (1.206.0) + aws-sdk-core (~> 3, >= 3.234.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + +GEM + remote: https://rubygems.org/ + specs: + ast (2.4.3) + aws-eventstream (1.4.0) + aws-partitions (1.1180.0) + aws-sdk-core (3.239.2) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + base64 (0.3.0) + bigdecimal (3.3.1) + bigdecimal (3.3.1-java) + concurrent-ruby (1.3.5) + jmespath (1.6.2) + json (2.13.2) + json (2.13.2-java) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + mustermann (3.0.4) + ruby2_keywords (~> 0.0.1) + nio4r (2.7.4) + nio4r (2.7.4-java) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-java) + racc (~> 1.4) + parallel (1.27.0) + parser (3.3.9.0) + ast (~> 2.4.1) + racc + prism (1.5.1) + puma (6.6.1) + nio4r (~> 2.0) + puma (6.6.1-java) + nio4r (~> 2.0) + racc (1.8.1) + racc (1.8.1-java) + rack (2.2.17) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rainbow (3.1.1) + regexp_parser (2.11.3) + rubocop (1.80.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.46.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.46.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) + tilt (~> 2.0) + tilt (2.6.1) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + +PLATFORMS + arm64-darwin-24 + universal-java-21 + +DEPENDENCIES + aws-sdk-kms! + aws-sdk-s3! + concurrent-ruby (~> 1.0) + json (~> 2.0) + nokogiri (~> 1.13) + puma (~> 6.0) + rubocop (~> 1.0) + sinatra (~> 3.0) + +RUBY VERSION + ruby 3.4.5p51 + +BUNDLED WITH + 2.6.9 diff --git a/test-server/ruby-v3-server/Makefile b/test-server/ruby-v3-server/Makefile new file mode 100644 index 00000000..331abac5 --- /dev/null +++ b/test-server/ruby-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 := 8092 + +build-server: + @echo "Building Ruby V3 server..." + bundle install + +start-server: + @if [ -f $(PID_FILE) ]; then \ + echo "❌ Error: Server already running. Stop before starting."; \ + exit 1; \ + fi; + @echo "Starting Ruby V3 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" \ + PORT=$(PORT) bundle exec ruby app.rb > server.log 2>&1 & echo $$! > $(PID_FILE) + @echo "Ruby 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/ruby-v3-server/README.md b/test-server/ruby-v3-server/README.md new file mode 100644 index 00000000..0c27b3d8 --- /dev/null +++ b/test-server/ruby-v3-server/README.md @@ -0,0 +1,74 @@ +# Ruby S3 Encryption Client Test Server + +This is a Ruby implementation of the S3 Encryption Client test server +that provides an invariant interface around the S3 Encryption Client v3. +It's designed to work alongside other implementations of test servers for cross-language compatibility testing. + +## Overview + +The server provides a REST API that wraps the AWS S3 Encryption Client v3, +allowing tests to verify that all language implementations behave consistently. + +## Endpoints + +- `POST /client` - Create a new S3 encryption client instance +- `PUT /object/{bucket}/{key}` - Encrypt and store an object +- `GET /object/{bucket}/{key}` - Retrieve and decrypt an object +- `GET /health` - Health check endpoint + +## Configuration + +The server runs on port **8092** by default. + +## Setup + +1. Install Ruby 3.x +2. Install dependencies: + + ```bash + cd test-server/ruby-v2-server + bundle install + ``` + +3. Set up AWS credentials (via AWS CLI, environment variables, or IAM roles) + +4. Start the server: + + ```bash + ruby app.rb + # or using Rack + bundle exec rackup -p 8092 + ``` + +## Usage + +The server is designed to be used by the Java test suite in `test-server/java-tests/`. +The tests will automatically discover and use this server for cross-language compatibility testing. + +### Environment Variables + +- `TEST_SERVER_KMS_KEY_ARN` - KMS key ARN for encryption (defaults to test key) +- `TEST_SERVER_S3_BUCKET` - S3 bucket for testing (defaults to test bucket) + +## Architecture + +- `app.rb` - Main Sinatra application +- `lib/client_manager.rb` - Manages S3 encryption client instances +- `lib/metadata_utils.rb` - Handles metadata serialization/deserialization +- `lib/error_handlers.rb` - Smithy-compliant error responses + +## Error Handling + +The server returns errors in the format expected by the Smithy model: + +- `GenericServerError` - Internal server errors +- `S3EncryptionClientError` - Errors from the S3 Encryption Client + +## Compatibility + +This server is compatible with: + +- S3 Encryption Client v3 +- Legacy v1 clients (when `enableLegacyWrappingAlgorithms` is true) +- Legacy v2 clients (when `???` is true) +- Cross-language testing with other implementations diff --git a/test-server/ruby-v3-server/app.rb b/test-server/ruby-v3-server/app.rb new file mode 100644 index 00000000..80ac972f --- /dev/null +++ b/test-server/ruby-v3-server/app.rb @@ -0,0 +1,241 @@ +require 'sinatra' +require 'json' +require_relative 'lib/client_manager' +require_relative 'lib/metadata_utils' +require_relative 'lib/error_handlers' +require_relative 'lib/logger' + +# See: https://github.com/ruby/openssl/issues/949 +Aws.use_bundled_cert! + +class S3ECRubyServer < Sinatra::Base + configure do + set :port, ENV['PORT'] || 8092 + set :bind, '0.0.0.0' + set :show_exceptions, false + set :raise_errors, false + end + + def initialize + super + @client_manager = ClientManager.new + S3ECLogger.info("S3EC_SERVER: Ruby V3 server initialized on port #{settings.port}") + end + + # Request logging middleware + before do + @request_id = S3ECLogger.generate_request_id + S3ECLogger.log_request(request.request_method, request.path_info, request.env, @request_id) + end + + # Response logging middleware + after do + S3ECLogger.log_response(response.status, @request_id) + end + + # Health check endpoint + get '/health' do + content_type :json + { status: 'OK', server: 'Ruby V3 S3EC Test Server', port: settings.port.to_i }.to_json + end + + # POST /client - Create S3 encryption client + post '/client' do + begin + S3ECLogger.debug("CLIENT_ENDPOINT [#{@request_id}]: Processing client creation request") + + # Parse request body + request_body = request.body.read + S3ECLogger.debug("CLIENT_ENDPOINT [#{@request_id}]: Request body size: #{request_body.length} bytes") + + parsed_data = JSON.parse(request_body) + config = parsed_data['config'] || {} + + S3ECLogger.debug("CLIENT_ENDPOINT [#{@request_id}]: Parsed config: #{config.inspect}") + + # Create client using client manager + client_id = @client_manager.create_client(config) + + S3ECLogger.info("CLIENT_ENDPOINT [#{@request_id}]: Successfully created client #{client_id}") + + # Return client ID + content_type :json + { clientId: client_id }.to_json + + rescue JSON::ParserError => e + S3ECLogger.log_error(e, { endpoint: '/client', operation: 'JSON parsing' }, @request_id) + ErrorHandlers.send_generic_server_error(self, "Invalid JSON in request body", 400) + rescue => e + S3ECLogger.log_error(e, { endpoint: '/client', operation: 'client creation', config: config }, @request_id) + ErrorHandlers.send_s3_encryption_client_error(self, "Failed to create client: #{e.message}") + end + end + + # PUT /object/{bucket}/{key} - Encrypt and put object + put '/object/:bucket/:key' do + bucket = params[:bucket] + key = params[:key] + client_id = request.env['HTTP_CLIENTID'] + + begin + S3ECLogger.debug("PUT_ENDPOINT [#{@request_id}]: Processing PUT request for s3://#{bucket}/#{key}") + + # Validate client ID + unless client_id + S3ECLogger.log_validation_error('ClientID', 'missing', @request_id) + ErrorHandlers.send_generic_server_error(self, "ClientID header is required", 400) + end + + # Get client from cache + client = @client_manager.get_client(client_id) + unless client + S3ECLogger.log_validation_error('ClientID', client_id, @request_id) + ErrorHandlers.send_generic_server_error(self, "No client found for ClientID: #{client_id}", 404) + end + + # Get request body + body = request.body.read + S3ECLogger.debug("PUT_ENDPOINT [#{@request_id}]: Request body size: #{body.length} bytes") + + # Parse metadata from header + metadata_header = request.env['HTTP_CONTENT_METADATA'] || '' + encryption_context = MetadataUtils.string_to_map(metadata_header) + S3ECLogger.log_metadata_processing('parse', metadata_header, encryption_context) + + # Prepare S3 put_object parameters + put_params = { + bucket: bucket, + key: key, + body: body + } + + # Add encryption context if present + put_params[:kms_encryption_context] = encryption_context unless encryption_context.empty? + + # Log S3 operation + S3ECLogger.log_s3_operation('put', bucket, key, encryption_context, "ClientID: #{client_id}, BodySize: #{body.length}") + + # Make the put_object request + response = client.put_object(put_params) + + S3ECLogger.info("PUT_ENDPOINT [#{@request_id}]: Successfully put object s3://#{bucket}/#{key}") + + # Prepare response metadata + response_metadata = MetadataUtils.map_to_array(encryption_context) + S3ECLogger.log_metadata_processing('response', encryption_context, response_metadata) + + # Return response matching Smithy model + content_type :json + { + bucket: bucket, + key: key, + metadata: response_metadata + }.to_json + + rescue Aws::S3::EncryptionV2::Errors::EncryptionError, Aws::S3::EncryptionV3::Errors::EncryptionError => e + S3ECLogger.log_error(e, { endpoint: '/put', error_category: 'EncryptionError' }, @request_id) + ErrorHandlers.send_s3_encryption_client_error(self, e.message) + rescue StandardError => e + # Handle generic server errors (return as GenericServerError) + S3ECLogger.log_error(e, { endpoint: '/put', error_category: 'generic_server' }, @request_id) + status_code = e.respond_to?(:code) ? e.code : 500 + ErrorHandlers.send_generic_server_error(self, e.message, status_code) + end + end + + # GET /object/{bucket}/{key} - Get and decrypt object + get '/object/:bucket/:key' do + bucket = params[:bucket] + key = params[:key] + client_id = request.env['HTTP_CLIENTID'] + + begin + S3ECLogger.debug("GET_ENDPOINT [#{@request_id}]: Processing GET request for s3://#{bucket}/#{key}") + + # Validate client ID + unless client_id + S3ECLogger.log_validation_error('ClientID', 'missing', @request_id) + ErrorHandlers.send_generic_server_error(self, "ClientID header is required", 400) + end + + # Get client from cache + client = @client_manager.get_client(client_id) + unless client + S3ECLogger.log_validation_error('ClientID', client_id, @request_id) + ErrorHandlers.send_generic_server_error(self, "No client found for ClientID: #{client_id}", 404) + end + + # Parse metadata from header + metadata_header = request.env['HTTP_CONTENT_METADATA'] || '' + encryption_context = MetadataUtils.string_to_map(metadata_header) + S3ECLogger.log_metadata_processing('parse', metadata_header, encryption_context) + + # Prepare S3 get_object parameters + get_params = { + bucket: bucket, + key: key + } + + # Add custom instruction file suffix if present + instruction_file_suffix = request.env['HTTP_INSTRUCTIONFILESUFFIX'] + if instruction_file_suffix && !instruction_file_suffix.empty? + get_params[:envelope_location] = :instruction_file + get_params[:instruction_file_suffix] = instruction_file_suffix + S3ECLogger.debug("GET_ENDPOINT [#{@request_id}]: Using custom instruction file suffix: #{instruction_file_suffix}") + elsif !encryption_context.empty? + get_params[:kms_encryption_context] = encryption_context + end + + # Log S3 operation + S3ECLogger.log_s3_operation('get', bucket, key, encryption_context, "ClientID: #{client_id}") + + # Make the get_object request + response = client.get_object(get_params) + + # Extract body and metadata + body = response.body.read + metadata = response.metadata || {} + + S3ECLogger.info("GET_ENDPOINT [#{@request_id}]: Successfully got object s3://#{bucket}/#{key}, BodySize: #{body.length}") + + # Set Content-Metadata header in response + metadata_str = MetadataUtils.map_to_string(metadata) + S3ECLogger.log_metadata_processing('response', metadata, metadata_str) + + headers['Content-Metadata'] = metadata_str unless metadata_str.empty? + + # Return the body as response + content_type 'application/octet-stream' + body + + rescue Aws::S3::EncryptionV2::Errors::DecryptionError, Aws::S3::EncryptionV3::Errors::DecryptionError => e + S3ECLogger.log_error(e, { endpoint: '/get', error_category: 'DecryptionError' }, @request_id) + ErrorHandlers.send_s3_encryption_client_error(self, e.message) + rescue StandardError => e + # Handle generic server errors (return as GenericServerError) + S3ECLogger.log_error(e, { endpoint: '/get', error_category: 'generic_server' }, @request_id) + status_code = e.respond_to?(:code) ? e.code : 500 + ErrorHandlers.send_generic_server_error(self, e.message, status_code) + end + end + + # Global error handler + error do + error = env['sinatra.error'] + context = { + endpoint: request.path_info, + method: request.request_method, + params: params, + error_type: 'global_error_handler' + } + + S3ECLogger.log_error(error, context, @request_id) + ErrorHandlers.send_generic_server_error(self, "Internal server error: #{error.message}") + end + + # Start server when run directly + if __FILE__ == $0 + S3ECLogger.info("S3EC_SERVER: Starting Ruby server on port #{settings.port}...") + run! + end +end diff --git a/test-server/ruby-v3-server/config.ru b/test-server/ruby-v3-server/config.ru new file mode 100644 index 00000000..99d3a689 --- /dev/null +++ b/test-server/ruby-v3-server/config.ru @@ -0,0 +1,3 @@ +require_relative 'app' + +run S3ECRubyServer diff --git a/test-server/ruby-v3-server/lib/client_manager.rb b/test-server/ruby-v3-server/lib/client_manager.rb new file mode 100644 index 00000000..5ee3f1ec --- /dev/null +++ b/test-server/ruby-v3-server/lib/client_manager.rb @@ -0,0 +1,134 @@ +require 'concurrent-ruby' +require 'securerandom' +require 'aws-sdk-s3' +require 'aws-sdk-kms' +require 'openssl' +require 'base64' +require_relative 'logger' + +# Manages S3 Encryption Client instances +class ClientManager + def initialize + @client_cache = Concurrent::Hash.new + @kms_client = Aws::KMS::Client.new(region: 'us-west-2') + S3ECLogger.info("CLIENT_MANAGER: Initialized with KMS client for us-west-2") + end + + # Create a new S3 encryption client and return its ID + def create_client(config) + # Extract all key material types + kms_key_id = config.dig('keyMaterial', 'kmsKeyId') + rsa_key_blob = config.dig('keyMaterial', 'rsaKey') + aes_key_blob = config.dig('keyMaterial', 'aesKey') + inst_file_put = config.dig('instructionFileConfig', 'enableInstructionFilePutObject') + content_alg = config.dig('encryptionAlgorithm') + + # Validate that only one key type is provided + key_count = [kms_key_id, rsa_key_blob, aes_key_blob].compact.count + raise 'KeyMaterial must contain exactly one non-null key type' unless key_count == 1 + + # translate between canonical AlgSuite and Ruby symbols + if content_alg.nil? || content_alg == 'ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY' + content_alg = :alg_aes_256_gcm_hkdf_sha512_commit_key + elsif content_alg == 'ALG_AES_256_GCM_IV12_TAG16_NO_KDF' + content_alg = :aes_gcm_no_padding + else + raise 'Unknown content encryption algorithm provided: ' + content_alg + end + + # Create S3 encryption client configuration + encryption_config = { + envelope_location: inst_file_put ? :instruction_file : :metadata, + content_encryption_schema: content_alg + } + + # Configure based on key type + if kms_key_id + encryption_config[:kms_key_id] = kms_key_id + encryption_config[:kms_client] = @kms_client + encryption_config[:key_wrap_schema] = :kms_context + elsif rsa_key_blob + # Parse RSA private key from PKCS8 format + key_bytes = Base64.decode64(rsa_key_blob) + rsa_key = OpenSSL::PKey::RSA.new(key_bytes) + encryption_config[:encryption_key] = rsa_key + encryption_config[:key_wrap_schema] = :rsa_oaep_sha1 + elsif aes_key_blob + # Extract AES key bytes + key_bytes = Base64.decode64(aes_key_blob) + encryption_config[:encryption_key] = key_bytes + encryption_config[:key_wrap_schema] = :aes_gcm + end + + # Apply additional configuration + encryption_config.tap do |hash| + if !config['commitmentPolicy'].nil? + hash[:commitment_policy] = case config['commitmentPolicy'] + when 'FORBID_ENCRYPT_ALLOW_DECRYPT' + :forbid_encrypt_allow_decrypt + when 'REQUIRE_ENCRYPT_ALLOW_DECRYPT' + :require_encrypt_allow_decrypt + when 'REQUIRE_ENCRYPT_REQUIRE_DECRYPT' + :require_encrypt_require_decrypt + else + raise "Unsupported commitment_policy " + config['commitmentPolicy'] + end + if config['commitmentPolicy'] == 'FORBID_ENCRYPT_ALLOW_DECRYPT' && config['encryptionAlgorithm'].nil? + hash[:content_encryption_schema] = :aes_gcm_no_padding + end + end + if !config['enableLegacyWrappingAlgorithms'].nil? || !config['enableLegacyUnauthenticatedModes'].nil? + legacy_modes = config['enableLegacyWrappingAlgorithms'] || config['enableLegacyUnauthenticatedModes'] + # Set security profile based on legacy wrapping algorithms setting + hash[:security_profile] = legacy_modes ? :v3_and_legacy : :v3 + end + end + + # Create the S3 encryption client + # Create the S3 encryption client with retry configuration for throttling + s3_client = Aws::S3::Client.new( + region: 'us-west-2', + retry_mode: 'adaptive', + retry_limit: 5, + retry_backoff: lambda { |c| sleep(2 ** c.retries * 0.3 * rand) } + ) + encryption_client = Aws::S3::EncryptionV3::Client.new( + client: s3_client, + **encryption_config + ) + + # Generate client ID and store in cache + client_id = SecureRandom.uuid + @client_cache[client_id] = encryption_client + + # Log client creation + S3ECLogger.log_client_creation(config, client_id) + S3ECLogger.log_cache_stats(@client_cache.size) + + client_id + end + + # Get a client by ID + def get_client(client_id) + client = @client_cache[client_id] + if client + S3ECLogger.log_client_cache_hit(client_id) + else + S3ECLogger.log_client_cache_miss(client_id) + end + client + end + + # Remove a client from cache (optional cleanup) + def remove_client(client_id) + removed = @client_cache.delete(client_id) + S3ECLogger.info("CLIENT_CACHE: Removed client #{client_id} from cache") if removed + S3ECLogger.log_cache_stats(@client_cache.size) + removed + end + + # Get cache size (for debugging) + def cache_size + @client_cache.size + end +end diff --git a/test-server/ruby-v3-server/lib/error_handlers.rb b/test-server/ruby-v3-server/lib/error_handlers.rb new file mode 100644 index 00000000..234a9a55 --- /dev/null +++ b/test-server/ruby-v3-server/lib/error_handlers.rb @@ -0,0 +1,42 @@ +# Error handling utilities to match Smithy error types +require 'json' + +class ErrorHandlers + # Create a response that matches the GenericServerError type from the Smithy model + # Used for internal server errors + def self.create_generic_server_error(message, status_code = 500) + { + status: status_code, + headers: { 'Content-Type' => 'application/json' }, + body: { + '__type' => 'software.amazon.encryption.s3#GenericServerError', + 'message' => message + }.to_json + } + end + + # Create a response that matches the S3EncryptionClientError type from the Smithy model + # Used for errors thrown by the S3 Encryption Client + def self.create_s3_encryption_client_error(message, status_code = 500) + { + status: status_code, + headers: { 'Content-Type' => 'application/json' }, + body: { + '__type' => 'software.amazon.encryption.s3#S3EncryptionClientError', + 'message' => message + }.to_json + } + end + + # Helper method to send error response in Sinatra + def self.send_generic_server_error(app, message, status_code = 500) + error_response = create_generic_server_error(message, status_code) + app.halt error_response[:status], error_response[:headers], error_response[:body] + end + + # Helper method to send S3EC error response in Sinatra + def self.send_s3_encryption_client_error(app, message, status_code = 500) + error_response = create_s3_encryption_client_error(message, status_code) + app.halt error_response[:status], error_response[:headers], error_response[:body] + end +end diff --git a/test-server/ruby-v3-server/lib/logger.rb b/test-server/ruby-v3-server/lib/logger.rb new file mode 100644 index 00000000..df8ad9db --- /dev/null +++ b/test-server/ruby-v3-server/lib/logger.rb @@ -0,0 +1,105 @@ +require 'logger' +require 'securerandom' + +# Centralized logging utility for the S3EC Ruby server +class S3ECLogger + def self.instance + @instance ||= new + end + + def initialize + @logger = Logger.new(STDOUT) + @logger.level = ENV['LOG_LEVEL'] ? Logger.const_get(ENV['LOG_LEVEL'].upcase) : Logger::INFO + @logger.formatter = proc do |severity, datetime, progname, msg| + "[RUBY IMPROVED #{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity}: #{msg}\n" + end + end + + # Generate a unique request ID for correlation + def self.generate_request_id + SecureRandom.hex(8) + end + + # Request/Response logging + def self.log_request(method, path, headers = {}, request_id = nil) + client_id = headers['HTTP_CLIENTID'] || headers['ClientID'] || 'none' + content_metadata = headers['HTTP_CONTENT_METADATA'] || headers['Content-Metadata'] || 'none' + + instance.logger.info("REQUEST [#{request_id}] #{method} #{path} | ClientID: #{client_id} | Metadata: #{content_metadata}") + end + + def self.log_response(status, request_id = nil, additional_info = "") + info_str = additional_info.empty? ? "" : " | #{additional_info}" + instance.logger.info("RESPONSE [#{request_id}] Status: #{status}#{info_str}") + end + + # Operation-level logging + def self.log_client_creation(config, client_id) + kms_key = config.dig('keyMaterial', 'kmsKeyId') || 'unknown' + legacy_enabled = config['enableLegacyWrappingAlgorithms'] || false + instance.logger.info("CLIENT_CREATION: Created S3EC client #{client_id} | KMS Key: #{kms_key} | Legacy: #{legacy_enabled}") + end + + def self.log_client_cache_hit(client_id) + instance.logger.debug("CACHE_HIT: Found client #{client_id} in cache") + end + + def self.log_client_cache_miss(client_id) + instance.logger.warn("CACHE_MISS: Client #{client_id} not found in cache") + end + + def self.log_cache_stats(cache_size) + instance.logger.debug("CACHE_STATS: Current client cache size: #{cache_size}") + end + + def self.log_s3_operation(operation, bucket, key, encryption_context = {}, additional_info = "") + enc_ctx_str = encryption_context.empty? ? "none" : encryption_context.inspect + info_str = additional_info.empty? ? "" : " | #{additional_info}" + instance.logger.info("S3_OPERATION: #{operation.upcase} s3://#{bucket}/#{key} | EncCtx: #{enc_ctx_str}#{info_str}") + end + + def self.log_metadata_processing(operation, input, output) + instance.logger.debug("METADATA_#{operation.upcase}: Input: #{input.inspect} | Output: #{output.inspect}") + end + + # Enhanced error logging + def self.log_error(error, context = {}, request_id = nil) + error_context = context.empty? ? "" : " | Context: #{context.inspect}" + instance.logger.error("ERROR [#{request_id}] #{error.class}: #{error.message}#{error_context}") + + if error.backtrace && instance.debug? + instance.logger.debug("ERROR_BACKTRACE [#{request_id}]:\n#{error.backtrace.join("\n")}") + end + end + + def self.log_validation_error(field, value, request_id = nil) + instance.logger.warn("VALIDATION_ERROR [#{request_id}] Invalid #{field}: #{value}") + end + + def self.log_aws_error(error, operation, request_id = nil) + instance.logger.error("AWS_ERROR [#{request_id}] #{operation} failed: #{error.class} - #{error.message}") + end + + # Standard logging methods + def self.debug(message) + instance.logger.debug(message) + end + + def self.info(message) + instance.logger.info(message) + end + + def self.warn(message) + instance.logger.warn(message) + end + + def self.error(message) + instance.logger.error(message) + end + + attr_reader :logger + + def debug? + @logger.debug? + end +end diff --git a/test-server/ruby-v3-server/lib/metadata_utils.rb b/test-server/ruby-v3-server/lib/metadata_utils.rb new file mode 100644 index 00000000..72015fcc --- /dev/null +++ b/test-server/ruby-v3-server/lib/metadata_utils.rb @@ -0,0 +1,50 @@ +# Utility class for handling metadata serialization/deserialization +# Matches the format used by Java and Python servers: [key]:[value],[key2]:[value2] +class MetadataUtils + # Convert metadata string to hash + # Input: "[user-defined-enc-ctx-key]:[user-defined-enc-ctx-value],[user-defined-enc-ctx-key-2]:[user-defined-enc-ctx-value-2]" + # Output: {"user-defined-enc-ctx-key" => "user-defined-enc-ctx-value", "user-defined-enc-ctx-key-2" => "user-defined-enc-ctx-value-2"} + def self.string_to_map(metadata_string) + return {} if metadata_string.nil? || metadata_string.empty? + + metadata = {} + entries = metadata_string.split(',') + + entries.each do |entry| + # Split on "]:[" to separate key and value + parts = entry.split(']:[') + if parts.length == 2 + # Remove remaining brackets from start and end + key = parts[0].delete_prefix("[") # Remove first character '[' + value = parts[1].delete_suffix("]") # Remove last character ']' + metadata[key] = value + else + raise "Malformed metadata list entry: #{entry}" + end + end + + metadata + end + + # Convert hash to metadata string + # Input: {"user-defined-enc-ctx-key" => "user-defined-enc-ctx-value", "user-defined-enc-ctx-key-2" => "user-defined-enc-ctx-value-2"} + # Output: "[user-defined-enc-ctx-key]:[user-defined-enc-ctx-value],[user-defined-enc-ctx-key-2]:[user-defined-enc-ctx-value-2]" + def self.map_to_string(metadata_hash) + return '' if metadata_hash.nil? || metadata_hash.empty? + + entries = metadata_hash.map do |key, value| + "[#{key}]:[#{value}]" + end + + entries.join(',') + end + + # Convert metadata hash to array format (for JSON responses) + def self.map_to_array(metadata_hash) + return [] if metadata_hash.nil? || metadata_hash.empty? + + metadata_hash.map do |key, value| + "[#{key}]:[#{value}]" + end + end +end diff --git a/test-server/ruby-v3-server/local-ruby-sdk b/test-server/ruby-v3-server/local-ruby-sdk new file mode 160000 index 00000000..93985e94 --- /dev/null +++ b/test-server/ruby-v3-server/local-ruby-sdk @@ -0,0 +1 @@ +Subproject commit 93985e94bbe8345cc7d615d1cdbcd7516ac16bcd diff --git a/test-server/spec-compliance-dashboard/.gitignore b/test-server/spec-compliance-dashboard/.gitignore new file mode 100644 index 00000000..c9e1b5bb --- /dev/null +++ b/test-server/spec-compliance-dashboard/.gitignore @@ -0,0 +1 @@ +compliance_homepage.html \ No newline at end of file diff --git a/test-server/spec-compliance-dashboard/generate_compliance_dashboard.py b/test-server/spec-compliance-dashboard/generate_compliance_dashboard.py new file mode 100644 index 00000000..d19f6c6e --- /dev/null +++ b/test-server/spec-compliance-dashboard/generate_compliance_dashboard.py @@ -0,0 +1,1049 @@ +#!/usr/bin/env python3 +""" +Self-contained script to generate compliance dashboard and all server reports. +Automatically discovers servers with .duvet/reports/report.html files and generates +individual reports using the enhanced report-based format with deep links, source traceability, +copy buttons, and comprehensive statistics. +""" + +import json +import re +import os +from pathlib import Path +from datetime import datetime + + +def parse_report_html(report_file_path): + """Parse the report.html file and extract specification data.""" + with open(report_file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Extract JSON from script tag with id="result" + start_marker = '" + + start_idx = content.find(start_marker) + if start_idx == -1: + raise ValueError("No result script tag found in HTML") + + start_idx += len(start_marker) + end_idx = content.find(end_marker, start_idx) + if end_idx == -1: + raise ValueError("No closing script tag found") + + json_content = content[start_idx:end_idx] + data = json.loads(json_content) + + # Convert report.html JSON structure to match snapshot structure + return convert_report_to_specifications(data) + + +def convert_report_to_specifications(data): + """Convert duvet report.html JSON structure to match snapshot structure.""" + specifications = {} + + for spec_path, spec in (data.get("specifications", {})).items(): + spec_data = { + "title": spec.get("title", "Unknown"), + "spec_path": spec_path, # Store the original spec path + "sections": {}, + } + + # Process sections - sections is a list, not a dict + for section in spec.get("sections", []): + section_data = { + "title": section.get("title", "Unknown"), + "section_id": section.get("id", "unknown"), # Store the section ID + "requirements": [], + } + + # Process requirements for this section + for req_id in section.get("requirements", []): + # Get annotation data + annotation = None + if "annotations" in data and isinstance(data["annotations"], list): + # annotations is a list indexed by req_id + if req_id < len(data["annotations"]): + annotation = data["annotations"][req_id] + + # Get status data + status = None + if "statuses" in data and isinstance(data["statuses"], dict): + status = data["statuses"].get(str(req_id)) + + if annotation and status: + # Parse status indicators (matching snapshot logic) + has_implementation = bool( + status.get("citation") + ) # Only citation counts as implementation + has_test = bool(status.get("test")) + has_exception = bool(status.get("exception")) + has_implication = bool(status.get("implication")) + has_partial_coverage = bool(status.get("incomplete")) + + # Determine completion status (matching snapshot rules exactly) + is_complete = ( + (has_implementation and has_test) or has_exception or has_implication + ) and not has_partial_coverage # Partial coverage means not complete + + # Collect related annotations for detailed status + related_sources = [] + if "related" in status: + for related_id in status["related"]: + if related_id < len(data["annotations"]): + related_annotation = data["annotations"][related_id] + source = related_annotation.get("source", "") + line = related_annotation.get("line", "") + annotation_type = related_annotation.get("type", "CITATION") + if source: + source_info = { + "source": source, + "line": line, + "type": annotation_type, + } + related_sources.append(source_info) + + requirement = { + "text": annotation.get("comment", "No comment available"), + "has_implementation": has_implementation, + "has_test": has_test, + "has_exception": has_exception, + "has_implication": has_implication, + "has_partial_coverage": has_partial_coverage, + "is_complete": is_complete, + "related_sources": related_sources, + } + + section_data["requirements"].append(requirement) + elif req_id < len(data.get("annotations", [])): + # Fallback: create requirement with basic info + annotation = data["annotations"][req_id] + requirement = { + "text": annotation.get("comment", f"Requirement {req_id}"), + "has_implementation": False, + "has_test": False, + "has_exception": False, + "has_implication": False, + "is_complete": False, + "related_sources": [], + } + section_data["requirements"].append(requirement) + + spec_data["sections"][section.get("title", "Unknown")] = section_data + + specifications[spec.get("title", "Unknown")] = spec_data + + return specifications + + +def get_spec_status(spec_data): + """Determine the overall status of a specification based on all its sections.""" + sections = spec_data.get("sections", {}) + + if not sections: + return "✅" # No sections means complete + + # Get status of each section + section_statuses = [] + for section_data in sections.values(): + requirements = section_data.get("requirements", []) + if not requirements: + section_statuses.append("✅") # Empty section is complete + else: + complete_reqs = sum(1 for req in requirements if req["is_complete"]) + total_reqs = len(requirements) + + if complete_reqs == total_reqs: + section_statuses.append("✅") # All requirements complete + elif complete_reqs > 0: + section_statuses.append("🟡") # Some requirements complete + else: + section_statuses.append("❌") # No requirements complete + + # Apply the corrected logic based on section statuses: + if all(status == "✅" for status in section_statuses): + return "✅" # Green check if all sections are green + elif any(status in ["✅", "🟡"] for status in section_statuses): + return "🟡" # Yellow if any section is green or yellow + else: + return "❌" # Red X if all sections are red X + + +def get_requirement_status(requirement): + """Get the status emoji for a single requirement.""" + if requirement["is_complete"]: + return "✅" + elif requirement.get("has_partial_coverage", False): + return "🟡" # Partial coverage - incomplete + elif requirement["has_implementation"] and requirement["related_sources"]: + return "🟡" # Has implementation but no test + else: + return "❌" # No implementation + + +def format_requirement_text(text): + """Format requirement text to style status metadata lines.""" + lines = text.split("\n") + formatted_lines = [] + + for line in lines: + # Check if line contains status metadata + if line.strip().startswith("Status:"): + formatted_lines.append(f'') + else: + formatted_lines.append(line) + + return "\n".join(formatted_lines) + + +def calculate_summary_statistics(specifications): + """Calculate summary statistics for all specifications.""" + total_sections = 0 + complete_sections = 0 + total_requirements = 0 + complete_requirements = 0 + + # Count requirements by implementation type + no_implementation = 0 + implementation_only = 0 + test_only = 0 + implementation_and_test = 0 + exception_count = 0 + implication_count = 0 + partial_coverage_count = 0 + + for spec_data in specifications.values(): + sections = spec_data.get("sections", {}) + total_sections += len(sections) + + for section_data in sections.values(): + requirements = section_data.get("requirements", []) + total_requirements += len(requirements) + + # Count complete requirements + section_complete_reqs = sum(1 for req in requirements if req["is_complete"]) + complete_requirements += section_complete_reqs + + # A section is complete if all its requirements are complete + if requirements and section_complete_reqs == len(requirements): + complete_sections += 1 + elif not requirements: # Empty section is considered complete + complete_sections += 1 + + # Count requirements by implementation type + for req in requirements: + if req["has_exception"]: + exception_count += 1 + elif req["has_implication"]: + implication_count += 1 + elif ( + req["has_implementation"] + and req["has_test"] + and not req.get("has_partial_coverage", False) + ): + implementation_and_test += 1 + elif req["has_implementation"] and not req.get("has_partial_coverage", False): + implementation_only += 1 + elif req["has_test"] and not req.get("has_partial_coverage", False): + test_only += 1 + else: + # Partial coverage gets counted as no implementation + no_implementation += 1 + + return { + "total_sections": total_sections, + "complete_sections": complete_sections, + "total_requirements": total_requirements, + "complete_requirements": complete_requirements, + "no_implementation": no_implementation, + "implementation_only": implementation_only, + "test_only": test_only, + "implementation_and_test": implementation_and_test, + "exception_count": exception_count, + "implication_count": implication_count, + "partial_coverage_count": partial_coverage_count, + } + + +def url_encode_spec_path(spec_path): + """URL encode the spec path for use in duvet report URLs.""" + import urllib.parse + + return urllib.parse.quote(spec_path, safe="") + + +def generate_spec_url(duvet_report_path, spec_path): + """Generate URL to a specific specification in the duvet report.""" + encoded_path = url_encode_spec_path(spec_path) + return f"{duvet_report_path}#/spec/{encoded_path}" + + +def generate_section_url(duvet_report_path, spec_path, section_id): + """Generate URL to a specific section in the duvet report.""" + encoded_path = url_encode_spec_path(spec_path) + return f"{duvet_report_path}#/spec/{encoded_path}/{section_id}" + + +def generate_github_url(source_path, line_number=None, github_base_url=None): + """Generate GitHub URL for a source file.""" + if not github_base_url: + return None + + # Convert local path to GitHub path + # Remove local-go-s3ec/ prefix if present + if source_path.startswith("local-go-s3ec/"): + github_path = source_path[len("local-go-s3ec/") :] + else: + github_path = source_path + + url = f"{github_base_url}/{github_path}" + if line_number: + url += f"#L{line_number}" + + return url + + +def load_template(template_path): + """Load a template file.""" + with open(template_path, "r", encoding="utf-8") as f: + return f.read() + + +def generate_enhanced_html_report(report_file_path, output_file_path, server_name): + """Generate an enhanced interactive HTML report using templates.""" + specifications = parse_report_html(report_file_path) + + # Load the report template + template_dir = Path(__file__).parent / "templates" + template = load_template(template_dir / "report_template.html") + + # Create relative path to the duvet report.html + duvet_report_path = ".duvet/reports/report.html" + + # GitHub base URL - can be configured for when deployed to GitHub Pages + github_base_url = None + + # Calculate summary statistics + stats = calculate_summary_statistics(specifications) + + # Calculate percentages for each implementation type + total_reqs = stats["total_requirements"] + if total_reqs > 0: + # Calculate raw percentages + impl_test_pct = (stats["implementation_and_test"] / total_reqs) * 100 + impl_only_pct = (stats["implementation_only"] / total_reqs) * 100 + test_only_pct = (stats["test_only"] / total_reqs) * 100 + exception_pct = (stats["exception_count"] / total_reqs) * 100 + implication_pct = (stats["implication_count"] / total_reqs) * 100 + no_impl_pct = (stats["no_implementation"] / total_reqs) * 100 + + # Ensure percentages add up to exactly 100% by using precise calculation + # and assigning any remainder to the largest segment + if total_reqs > 0: + # Calculate exact percentages using integer arithmetic to avoid floating point errors + percentages_data = [ + (stats["implementation_and_test"], "impl_test"), + (stats["implication_count"], "implication"), + (stats["exception_count"], "exception"), + (stats["implementation_only"], "impl_only"), + (stats["no_implementation"], "no_impl"), + ] + + # Calculate percentages with high precision, then distribute remainder + total_allocated = 0.0 + calculated_percentages = {} + + # Calculate all but the last percentage + for i, (count, name) in enumerate(percentages_data[:-1]): + pct = round((count / total_reqs) * 100, 1) + calculated_percentages[name] = pct + total_allocated += pct + + # Last segment gets the remainder to ensure exactly 100% + last_count, last_name = percentages_data[-1] + calculated_percentages[last_name] = round(100.0 - total_allocated, 1) + + # Assign back to variables + impl_test_pct = calculated_percentages["impl_test"] + implication_pct = calculated_percentages["implication"] + exception_pct = calculated_percentages["exception"] + impl_only_pct = calculated_percentages["impl_only"] + no_impl_pct = calculated_percentages["no_impl"] + else: + impl_test_pct = impl_only_pct = test_only_pct = exception_pct = implication_pct = ( + no_impl_pct + ) = 0 + + # Generate summary statistics HTML with color-coded progress bars + content_html = f""" +
+
+
+
+ Requirements by Implementation Type + {stats['complete_requirements']}/{stats['total_requirements']} completed +
+
+
+
+
+
+
+
+
+
+ +
+
+
{stats['implementation_and_test']}
+
Implementation + Test
+
+
+
{stats['implication_count']}
+
Implication
+
+
+
{stats['exception_count']}
+
Exception
+
+
+
{stats['implementation_only']}
+
Implementation Only
+
+
+
{stats['no_implementation']}
+
No Implementation
+
+
+
{stats['total_requirements']}
+
Total
+
+
+
+ """ + + # Generate content for each specification + spec_counter = 0 + + for spec_title, spec_data in specifications.items(): + status_icon = get_spec_status(spec_data) + sections = spec_data.get("sections", {}) + + # Calculate requirement-level progress for this spec + spec_total_requirements = 0 + spec_complete_requirements = 0 + + for section_data in sections.values(): + section_requirements = section_data.get("requirements", []) + spec_total_requirements += len(section_requirements) + spec_complete_requirements += sum( + 1 for req in section_requirements if req["is_complete"] + ) + + # Determine alternating background class + row_class = "even" if spec_counter % 2 == 0 else "odd" + spec_counter += 1 + + # Generate spec-specific URL + spec_url = generate_spec_url(duvet_report_path, spec_data["spec_path"]) + + # Calculate spec-level statistics + spec_impl_test = 0 + spec_implication = 0 + spec_exception = 0 + spec_impl_only = 0 + spec_no_impl = 0 + + for section_data in sections.values(): + section_requirements = section_data.get("requirements", []) + for req in section_requirements: + if req["has_implementation"] and req["has_test"]: + spec_impl_test += 1 + elif req["has_implication"]: + spec_implication += 1 + elif req["has_exception"]: + spec_exception += 1 + elif req["has_implementation"]: + spec_impl_only += 1 + else: + spec_no_impl += 1 + + # Calculate percentages for spec progress bar + if spec_total_requirements > 0: + spec_impl_test_pct = (spec_impl_test / spec_total_requirements) * 100 + spec_implication_pct = (spec_implication / spec_total_requirements) * 100 + spec_exception_pct = (spec_exception / spec_total_requirements) * 100 + spec_impl_only_pct = (spec_impl_only / spec_total_requirements) * 100 + spec_no_impl_pct = (spec_no_impl / spec_total_requirements) * 100 + else: + spec_impl_test_pct = spec_implication_pct = spec_exception_pct = spec_impl_only_pct = ( + spec_no_impl_pct + ) = 0 + + content_html += f""" +
+
+
+ {status_icon} + {spec_title} + ({spec_complete_requirements}/{spec_total_requirements} completed) + 🔗 +
+ +
+ +
+""" + + # Add sections within each specification + for section_title, section_data in sections.items(): + section_requirements = section_data.get("requirements", []) + section_complete = sum(1 for req in section_requirements if req["is_complete"]) + section_total = len(section_requirements) + + # Skip sections with no requirements at all + if section_total == 0: + continue + + # Determine section status using the corrected logic + # Get individual requirement statuses + req_statuses = [get_requirement_status(req) for req in section_requirements] + + if all(status == "✅" for status in req_statuses): + section_status = "✅" # All requirements are green + elif any(status in ["✅", "🟡"] for status in req_statuses): + section_status = "🟡" # Any requirement is green or yellow + else: + section_status = "❌" # All requirements are red X + + section_id = f"{spec_title.replace(' ', '_')}_{section_title.replace(' ', '_').replace('#', '').replace('-', '_')}" + + # Generate section-specific URL + section_url = generate_section_url( + duvet_report_path, spec_data["spec_path"], section_data["section_id"] + ) + + # Generate local file path for this section + local_file_path = f"{spec_data['spec_path']}#{section_data['section_id']}" + + # Calculate section-level statistics + section_impl_test = sum( + 1 for req in section_requirements if req["has_implementation"] and req["has_test"] + ) + section_implication = sum(1 for req in section_requirements if req["has_implication"]) + section_exception = sum(1 for req in section_requirements if req["has_exception"]) + section_impl_only = sum( + 1 + for req in section_requirements + if req["has_implementation"] + and not req["has_test"] + and not req["has_exception"] + and not req["has_implication"] + ) + section_no_impl = sum( + 1 + for req in section_requirements + if not req["has_implementation"] + and not req["has_test"] + and not req["has_exception"] + and not req["has_implication"] + ) + + # Calculate percentages for section progress bar + if section_total > 0: + section_impl_test_pct = (section_impl_test / section_total) * 100 + section_implication_pct = (section_implication / section_total) * 100 + section_exception_pct = (section_exception / section_total) * 100 + section_impl_only_pct = (section_impl_only / section_total) * 100 + section_no_impl_pct = (section_no_impl / section_total) * 100 + else: + section_impl_test_pct = section_implication_pct = section_exception_pct = ( + section_impl_only_pct + ) = section_no_impl_pct = 0 + + content_html += f""" +
+
+
+ {section_status} + {section_title} + ({section_complete}/{section_total} completed) + 🔗 +
+ +
+
+ +
+ {local_file_path} + +
+""" + + # Add requirements within each section + req_counter = 1 + for requirement in section_requirements: + req_status = get_requirement_status(requirement) + req_text = format_requirement_text(requirement["text"]) + + # Build detailed source information with GitHub links - one bullet per source + sources_html = "" + if requirement["related_sources"]: + source_bullets = [] + for source_info in requirement["related_sources"]: + source_type = source_info["type"] + source_path = source_info["source"] + line_num = source_info["line"] + + # Generate GitHub URL if possible + github_url = generate_github_url(source_path, line_num, github_base_url) + + if github_url and source_path.endswith(".go"): + # Create clickable link for Go source files + source_display = f'{source_path}' + if line_num: + source_display += f":{line_num}" + source_display += "" + else: + # Plain text for non-Go files or when no GitHub URL + source_display = source_path + if line_num: + source_display += f":{line_num}" + + type_display = source_type.lower() + # Add partial indicator if this requirement has partial coverage + if requirement.get("has_partial_coverage", False): + type_display = f"partial {type_display}" + source_bullets.append(f"• {type_display}: {source_display}") + + sources_html = ( + '
' + + "
".join(source_bullets) + + "
" + ) + else: + sources_html = '
• no implementation found
' + + # Determine requirement type for filtering + if requirement["has_exception"]: + req_type = "exception" + elif requirement["has_implication"]: + req_type = "implication" + elif ( + requirement["has_implementation"] + and requirement["has_test"] + and not requirement.get("has_partial_coverage", False) + ): + req_type = "impl-test" + elif requirement["has_implementation"] and not requirement.get( + "has_partial_coverage", False + ): + req_type = "impl-only" + else: + # Partial coverage and no implementation both get "none" type + req_type = "none" + + # Prepare requirement text for copying (clean version without HTML) + clean_req_text = requirement["text"].replace("\n", " ").strip() + # Escape single quotes for JavaScript + clean_req_text = clean_req_text.replace("'", "\\'") + copy_text = f"//# {clean_req_text}" + + content_html += f""" +
+
+ Requirement {req_counter}: + {req_status} + +
+
{req_text}
+ {sources_html} +
+""" + req_counter += 1 + + content_html += """ +
+
+""" + + content_html += """ +
+
+""" + + # Replace placeholders in template + html_content = template.format(server_name=server_name, content=content_html) + + # Write the HTML file + with open(output_file_path, "w", encoding="utf-8") as f: + f.write(html_content) + + +def generate_server_report(server_path, server_name): + """Generate individual server report using the enhanced report-based format.""" + report_file = server_path / ".duvet" / "reports" / "report.html" + + if not report_file.exists(): + return None + + try: + # Parse the report directly + specifications = parse_report_html(report_file) + + # Generate the enhanced HTML report + html_output_file = server_path / "compliance_summary_report.html" + generate_enhanced_html_report(report_file, html_output_file, server_name) + + # Calculate detailed statistics + stats = calculate_summary_statistics(specifications) + + # Calculate overall status based on actual implementation progress + total_reqs = stats.get("total_requirements", 0) + complete_reqs = stats.get("complete_requirements", 0) + + if total_reqs == 0: + overall_status = "❌" # No requirements means not compliant + elif complete_reqs == total_reqs: + overall_status = "✅" # All requirements complete + elif complete_reqs > 0: + overall_status = "🟡" # Some requirements complete + else: + overall_status = "❌" # No requirements complete + + # Calculate spec-level status + spec_statuses = {} + for spec_title, spec_data in specifications.items(): + spec_statuses[spec_title] = get_spec_status(spec_data) + + total_specs = len(specifications) + complete_specs = sum(1 for status in spec_statuses.values() if status == "✅") + + return { + "name": server_name, + "status": overall_status, + "total_specs": total_specs, + "complete_specs": complete_specs, + "total_sections": stats["total_sections"], + "complete_sections": stats["complete_sections"], + "total_requirements": stats["total_requirements"], + "complete_requirements": stats["complete_requirements"], + "report_file": f"../{server_name}/compliance_summary_report.html", + "specifications": spec_statuses, + "stats": stats, # Include full stats for homepage display + } + + except Exception as e: + print(f"Error processing {server_name}: {e}") + return None + + +def generate_expected_output(report_file_path, output_file_path): + """Generate the expected output format from report.html.""" + specifications = parse_report_html(report_file_path) + + output_lines = [] + for spec_title, spec_data in specifications.items(): + status_icon = get_spec_status(spec_data) + output_lines.append(f"{spec_title}: {status_icon}") + + # Write the output file + with open(output_file_path, "w", encoding="utf-8") as f: + f.write("\n".join(output_lines)) + + +def generate_stats_output(report_file_path, output_file_path): + """Generate detailed statistics output for dashboard use.""" + specifications = parse_report_html(report_file_path) + stats = calculate_summary_statistics(specifications) + + # Write stats as JSON for easy parsing + import json + + with open(output_file_path, "w", encoding="utf-8") as f: + json.dump(stats, f, indent=2) + + +def generate_homepage(servers_info, output_file): + """Generate the main homepage with links to all server reports using templates.""" + + current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Load the homepage template + template_dir = Path(__file__).parent / "templates" + template = load_template(template_dir / "homepage_template.html") + + content_html = "" + + if servers_info: + # Calculate overall statistics + total_servers = len(servers_info) + compliant_servers = sum(1 for server in servers_info if server["status"] == "✅") + partial_servers = sum(1 for server in servers_info if server["status"] == "🟡") + non_compliant_servers = sum(1 for server in servers_info if server["status"] == "❌") + + # Add compact dark mode summary header + content_html += f""" +
+
+
+ {total_servers} +
Total
+
+
+ {compliant_servers} +
Compliant
+
+
+ {partial_servers} +
Partial
+
+
+ {non_compliant_servers} +
Missing
+
+
+
+ +
+""" + + # Generate server cards with detailed statistics + for server in sorted(servers_info, key=lambda x: x["name"]): + # Get detailed stats for this server + server_stats = server.get("stats", {}) + + # Calculate percentages for each implementation type + total_reqs = server_stats.get("total_requirements", 0) + if total_reqs > 0: + # Calculate raw percentages + impl_test_pct = (server_stats.get("implementation_and_test", 0) / total_reqs) * 100 + impl_only_pct = (server_stats.get("implementation_only", 0) / total_reqs) * 100 + test_only_pct = (server_stats.get("test_only", 0) / total_reqs) * 100 + exception_pct = (server_stats.get("exception_count", 0) / total_reqs) * 100 + implication_pct = (server_stats.get("implication_count", 0) / total_reqs) * 100 + no_impl_pct = (server_stats.get("no_implementation", 0) / total_reqs) * 100 + + # Ensure percentages add up to exactly 100% by using precise calculation + if total_reqs > 0: + # Calculate exact percentages and distribute remainder to largest segment + percentages_data = [ + (server_stats.get("implementation_and_test", 0), "impl_test"), + (server_stats.get("implication_count", 0), "implication"), + (server_stats.get("exception_count", 0), "exception"), + (server_stats.get("implementation_only", 0), "impl_only"), + (server_stats.get("no_implementation", 0), "no_impl"), + ] + + # Calculate percentages with high precision, then distribute remainder + total_allocated = 0.0 + calculated_percentages = {} + + # Calculate all but the last percentage + for i, (count, name) in enumerate(percentages_data[:-1]): + pct = round((count / total_reqs) * 100, 1) + calculated_percentages[name] = pct + total_allocated += pct + + # Last segment gets the remainder to ensure exactly 100% + last_count, last_name = percentages_data[-1] + calculated_percentages[last_name] = round(100.0 - total_allocated, 1) + + # Assign back to variables + impl_test_pct = calculated_percentages["impl_test"] + implication_pct = calculated_percentages["implication"] + exception_pct = calculated_percentages["exception"] + impl_only_pct = calculated_percentages["impl_only"] + no_impl_pct = calculated_percentages["no_impl"] + else: + impl_test_pct = impl_only_pct = test_only_pct = exception_pct = implication_pct = ( + no_impl_pct + ) = 0 + + content_html += f""" +
+
+
{server['name']}
+
{server['status']}
+
+
+
+
+ Requirements Progress + {server_stats.get('complete_requirements', 0)}/{server_stats.get('total_requirements', 0)} completed +
+
+
+
+
+
+
+
+
+ +
+
+
{server_stats.get('implementation_and_test', 0)}
+
Impl+Test
+
+
+
{server_stats.get('implication_count', 0)}
+
Implication
+
+
+
{server_stats.get('exception_count', 0)}
+
Exception
+
+
+
{server_stats.get('implementation_only', 0)}
+
Impl Only
+
+
+
{server_stats.get('no_implementation', 0)}
+
None
+
+
+
{server_stats.get('total_requirements', 0)}
+
Total
+
+
+
+ +
+""" + + content_html += """ +
+""" + else: + content_html += """ +
+

No servers with compliance reports found.

+

Make sure servers have .duvet/reports/report.html files.

+
+""" + + # Replace placeholders in template + html_content = template.format(timestamp=current_time, content=content_html) + + # Write the HTML file + with open(output_file, "w", encoding="utf-8") as f: + f.write(html_content) + + +def discover_servers(): + """Discover all servers with .duvet/reports/report.html files.""" + servers_info = [] + # Get the test-server directory (parent of spec-compliance-dashboard) + test_server_dir = Path(__file__).parent.parent + + # Look for directories with .duvet/reports/report.html + for item in test_server_dir.iterdir(): + if ( + item.is_dir() + and not item.name.startswith(".") + and item.name != "spec-compliance-dashboard" + ): + duvet_report = item / ".duvet" / "reports" / "report.html" + if duvet_report.exists(): + server_info = generate_server_report(item, item.name) + if server_info: + servers_info.append(server_info) + print(f"Processed server: {item.name}") + + return servers_info + + +def main(): + """Main function to generate both individual server reports and dashboard.""" + import sys + + # Check if server directory is provided as argument (for single server mode) + if len(sys.argv) > 1: + server_dir = Path(sys.argv[1]) + server_name = sys.argv[2] if len(sys.argv) > 2 else server_dir.name + + report_file = server_dir / ".duvet" / "reports" / "report.html" + html_output_file = server_dir / "compliance_summary_report.html" + expected_output_file = server_dir / "expected_output_report.txt" + + if not report_file.exists(): + print(f"Error: Report file not found at {report_file}") + return 1 + + try: + # Generate HTML report + generate_enhanced_html_report(report_file, html_output_file, server_name) + print(f"Interactive HTML report generated: {html_output_file}") + + # Generate expected output + generate_expected_output(report_file, expected_output_file) + print(f"Expected output generated: {expected_output_file}") + + # Generate stats output for dashboard + stats_output_file = server_dir / "compliance_stats.json" + generate_stats_output(report_file, stats_output_file) + print(f"Stats output generated: {stats_output_file}") + + return 0 + except Exception as e: + print(f"Error generating reports: {e}") + return 1 + else: + # Dashboard mode - discover all servers and generate dashboard + try: + print("Discovering servers with compliance reports...") + servers_info = discover_servers() + + if servers_info: + print(f"Found {len(servers_info)} servers with reports") + + # Generate the main dashboard homepage + homepage_file = Path(__file__).parent / "compliance_homepage.html" + generate_homepage(servers_info, homepage_file) + print(f"Dashboard homepage generated: {homepage_file}") + + return 0 + else: + print("No servers with .duvet/reports/report.html found") + return 1 + + except Exception as e: + print(f"Error generating dashboard: {e}") + return 1 + + +if __name__ == "__main__": + exit(main()) diff --git a/test-server/spec-compliance-dashboard/templates/homepage_styles.css b/test-server/spec-compliance-dashboard/templates/homepage_styles.css new file mode 100644 index 00000000..466f1393 --- /dev/null +++ b/test-server/spec-compliance-dashboard/templates/homepage_styles.css @@ -0,0 +1,335 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + line-height: 1.6; + margin: 0; + padding: 20px; + background-color: #0d1117; + color: #c9d1d9; +} + +.container { + max-width: 1200px; + margin: 0 auto; + background: #161b22; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + border: 1px solid #30363d; + overflow: hidden; +} + +.header { + background: #21262d; + color: #c9d1d9; + padding: 15px 20px; + text-align: center; + border-bottom: 1px solid #30363d; +} + +.header h1 { + margin: 0; + font-size: 1.8em; + font-weight: 400; +} + +.header p { + margin: 5px 0 0 0; + opacity: 0.9; + font-size: 0.9em; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + padding: 30px; + background: #0d1117; +} + +.stat-card { + background: #161b22; + padding: 20px; + border-radius: 6px; + text-align: center; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + border: 1px solid #30363d; +} + +.stat-number { + font-size: 2em; + font-weight: bold; + color: #c9d1d9; +} + +.stat-label { + color: #8b949e; + font-size: 0.9em; + margin-top: 5px; +} + +.servers-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + gap: 20px; + padding: 30px; + max-width: 1200px; + margin: 0 auto; +} + +.server-card { + background: #161b22; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + border: 1px solid #30363d; + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s; +} + +.server-card:hover { + transform: translateY(-2px); + box-shadow: 0 3px 6px rgba(0,0,0,0.4); +} + +.server-header { + padding: 12px 16px; + background: #21262d; + color: #c9d1d9; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #30363d; +} + +.server-name { + font-size: 1.2em; + font-weight: 600; +} + +.server-status { + font-size: 1.5em; +} + +.server-body { + padding: 20px; +} + +.progress-bar { + background: #0d1117; + border-radius: 6px; + height: 8px; + margin: 15px 0; + overflow: hidden; + border: 1px solid #30363d; +} + +.progress-fill { + height: 100%; + background: #238636; + border-radius: 6px; + transition: width 0.3s ease; +} + +.progress-bar.color-coded { + display: flex; + height: 12px; +} + +.progress-segment { + height: 100%; + transition: width 0.3s ease; + position: relative; +} + +.progress-segment:first-child { + border-radius: 6px 0 0 6px; +} + +.progress-segment:last-child { + border-radius: 0 6px 6px 0; +} + +.progress-segment:only-child { + border-radius: 6px; +} + +.progress-segment.impl-test { + background: #28a745; +} + +.progress-segment.impl-only { + background: #ffc107; +} + +.progress-segment.test-only { + background: #6c757d; +} + +.progress-segment.exception { + background: #87ceeb; +} + +.progress-segment.implication { + background: #dda0dd; +} + +.progress-segment.no-impl { + background: #dc3545; +} + +.progress-item { + margin-bottom: 15px; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.progress-label { + color: #c9d1d9; + font-weight: 500; + font-size: 0.9em; +} + +.progress-count { + color: #8b949e; + font-size: 0.8em; +} + +.breakdown-grid-compact { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; + margin-top: 10px; +} + +.breakdown-item-compact { + background: #0d1117; + padding: 8px 4px; + border-radius: 4px; + text-align: center; + border: 1px solid #30363d; +} + +.breakdown-number-compact { + font-size: 1.1em; + font-weight: bold; + color: #c9d1d9; + margin-bottom: 2px; +} + +.breakdown-label-compact { + color: #8b949e; + font-size: 0.7em; + line-height: 1.2; +} + +/* Regular breakdown grid (used by the generated HTML) */ +.breakdown-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + gap: 8px; + margin-top: 10px; +} + +.breakdown-item { + background: transparent; + padding: 8px 4px; + border-radius: 4px; + text-align: center; + border: none; +} + +.breakdown-number { + font-size: 1.1em; + font-weight: bold; + color: #c9d1d9; + margin-bottom: 2px; +} + +.breakdown-label { + color: #8b949e; + font-size: 0.7em; + line-height: 1.2; +} + +.server-summary { + margin-top: 15px; +} + +.summary-row { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.summary-row:last-child { + margin-bottom: 0; +} + +.summary-item { + display: flex; + flex-direction: column; + align-items: center; + flex: 1; +} + +.summary-number { + font-size: 1.2em; + font-weight: bold; + margin-bottom: 2px; +} + +.summary-label { + color: #8b949e; + font-size: 0.75em; + text-align: center; +} + +.server-stats { + display: flex; + justify-content: space-between; + margin-top: 15px; + font-size: 0.9em; + color: #8b949e; +} + +.server-footer { + padding: 15px 20px; + background: #0d1117; + border-top: 1px solid #30363d; + text-align: center; +} + +.view-report-btn { + display: inline-block; + padding: 10px 20px; + background: #238636; + color: white; + text-decoration: none; + border-radius: 6px; + transition: background-color 0.2s; + font-size: 0.9em; +} + +.view-report-btn:hover { + background: #2ea043; +} + +.no-data { + text-align: center; + padding: 40px; + color: #8b949e; +} + +.footer { + padding: 20px; + text-align: center; + background: #21262d; + color: #8b949e; + font-size: 0.9em; + border-top: 1px solid #30363d; +} diff --git a/test-server/spec-compliance-dashboard/templates/homepage_template.html b/test-server/spec-compliance-dashboard/templates/homepage_template.html new file mode 100644 index 00000000..eddc8a4d --- /dev/null +++ b/test-server/spec-compliance-dashboard/templates/homepage_template.html @@ -0,0 +1,21 @@ + + + + + + Spec Compliance Dashboard + + + +
+
+

Spec Compliance Dashboard

+

Last updated: {timestamp}

+
+ {content} + +
+ + diff --git a/test-server/spec-compliance-dashboard/templates/report_template.html b/test-server/spec-compliance-dashboard/templates/report_template.html new file mode 100644 index 00000000..94d06ff8 --- /dev/null +++ b/test-server/spec-compliance-dashboard/templates/report_template.html @@ -0,0 +1,276 @@ + + + + + + {server_name} - Duvet Compliance Report + + + +
+ + {content} +
+ + + + diff --git a/test-server/spec-compliance-dashboard/templates/styles.css b/test-server/spec-compliance-dashboard/templates/styles.css new file mode 100644 index 00000000..161175d6 --- /dev/null +++ b/test-server/spec-compliance-dashboard/templates/styles.css @@ -0,0 +1,387 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + line-height: 1.6; + margin: 0; + padding: 20px; + background-color: #0d1117; + color: #c9d1d9; +} + +.container { + max-width: 1000px; + margin: 0 auto; + background: #161b22; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + border: 1px solid #30363d; + overflow: hidden; +} + +.header { + background: #21262d; + color: #c9d1d9; + padding: 8px 15px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #30363d; +} + +.header h1 { + margin: 0; + font-size: 1.2em; + font-weight: 500; +} + +.nav-link { + color: white; + text-decoration: none; + font-size: 0.9em; + opacity: 0.9; +} + +.nav-link:hover { + opacity: 1; + text-decoration: underline; +} + +.spec-section { + border-bottom: 1px solid #30363d; +} + +.spec-section.even { + background: #161b22; +} + +.spec-section.odd { + background: #0d1117; +} + +.spec-header { + padding: 15px 20px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + background: transparent; + transition: background-color 0.2s; + color: #c9d1d9; +} + +.spec-header:hover { + background: #21262d; +} + +.spec-title { + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; +} + +.completion-count { + color: #8b949e; + font-size: 0.8em; + font-weight: 400; +} + +.status-emoji { + font-size: 20px; +} + +.expand-icon { + font-size: 14px; + transition: transform 0.2s; +} + +.spec-content { + display: none; + padding: 20px; + background: transparent; +} + +.spec-content.expanded { + display: block; +} + +.requirement-item { + margin-bottom: 15px; + padding: 15px; + background: #161b22; + border-radius: 6px; + box-shadow: 0 1px 3px rgba(0,0,0,0.3); + border: 1px solid #30363d; + color: #c9d1d9; +} + +.requirement-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + color: #c9d1d9; +} + +.requirement-id { + font-weight: bold; + color: #c9d1d9; +} + +.requirement-status { + font-size: 16px; +} + +.requirement-text { + color: #c9d1d9; + white-space: pre-wrap; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 14px; + line-height: 1.4; +} + +.section-item { + margin-bottom: 10px; + border-radius: 6px; + background: #21262d; + border: 1px solid #30363d; +} + +.section-header { + padding: 12px 15px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + background: transparent; + transition: background-color 0.2s; + color: #c9d1d9; +} + +.section-header:hover { + background: #30363d; +} + +.section-title { + font-size: 16px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +} + +.section-content { + display: none; + padding: 15px; + background: transparent; +} + +.section-content.expanded { + display: block; +} + +.requirement-metadata { + color: #8b949e; + font-size: 12px; + margin-top: 8px; + font-style: italic; +} + +.status-metadata { + color: #6e7681; + font-size: 12px; + font-style: italic; +} + +.summary-stats { + padding: 20px; + background: #0d1117; + border-bottom: 1px solid #30363d; +} + +.summary-stats h2 { + margin: 0 0 15px 0; + color: #c9d1d9; + font-size: 1.4em; + font-weight: 600; +} + +.summary-stats h3 { + margin: 20px 0 10px 0; + color: #c9d1d9; + font-size: 1.1em; + font-weight: 500; +} + +.progress-section { + margin-bottom: 20px; +} + +.progress-item { + margin-bottom: 15px; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.progress-label { + color: #c9d1d9; + font-weight: 500; +} + +.progress-count { + color: #8b949e; + font-size: 0.9em; +} + +.progress-bar { + background: #21262d; + border-radius: 6px; + height: 8px; + overflow: hidden; + border: 1px solid #30363d; +} + +.progress-fill { + height: 100%; + background: #238636; + border-radius: 6px; + transition: width 0.3s ease; +} + +.progress-bar.color-coded { + display: flex; + height: 12px; +} + +.progress-segment { + height: 100%; + transition: width 0.3s ease; + position: relative; +} + +.progress-segment:first-child { + border-radius: 6px 0 0 6px; +} + +.progress-segment:last-child { + border-radius: 0 6px 6px 0; +} + +.progress-segment:only-child { + border-radius: 6px; +} + +.progress-segment.impl-test { + background: #28a745; +} + +.progress-segment.impl-only { + background: #ffc107; +} + +.progress-segment.test-only { + background: #6c757d; +} + +.progress-segment.exception { + background: #87ceeb; +} + +.progress-segment.implication { + background: #dda0dd; +} + +.progress-segment.no-impl { + background: #dc3545; +} + +.breakdown-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + gap: 10px; +} + +.breakdown-grid.single-row { + grid-template-columns: repeat(6, 1fr); + grid-template-rows: 1fr; +} + +.breakdown-item { + background: transparent; + padding: 12px; + border-radius: 6px; + text-align: center; + border: none; + transition: all 0.2s ease; +} + +.breakdown-item.clickable-filter { + cursor: pointer; + border: 1px solid transparent; +} + +.breakdown-item.clickable-filter:hover { + background: #21262d; + border: 1px solid #30363d; + transform: translateY(-1px); +} + +.breakdown-item.active-filter { + background: #21262d; + border: 2px solid #58a6ff; + box-shadow: 0 0 8px rgba(88, 166, 255, 0.3); +} + +.breakdown-number { + font-size: 1.4em; + font-weight: bold; + color: #c9d1d9; + margin-bottom: 3px; +} + +.breakdown-label { + color: #8b949e; + font-size: 0.8em; +} + +.breakdown-header { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + padding: 10px 0; + border-bottom: 1px solid #30363d; + margin-bottom: 15px; +} + +.breakdown-header:hover { + background: #21262d; + border-radius: 6px; + padding: 10px 15px; + margin: 0 -15px 15px -15px; +} + +.breakdown-header h3 { + margin: 0; +} + +.pie-chart-container { + background: #161b22; + padding: 20px; + border-radius: 6px; + border: 1px solid #30363d; + margin-top: 15px; + justify-content: center; + align-items: center; +} + +.pie-chart-container canvas { + max-width: 100%; + height: auto; +} diff --git a/test-server/spec-compliance-dashboard/templates/summary_stats_template.html b/test-server/spec-compliance-dashboard/templates/summary_stats_template.html new file mode 100644 index 00000000..0415d138 --- /dev/null +++ b/test-server/spec-compliance-dashboard/templates/summary_stats_template.html @@ -0,0 +1,63 @@ +
+

Summary Statistics

+
+
+
+ Sections Implemented + {complete_sections}/{total_sections} +
+
+
+
+
+
+
+ Requirements Implemented + {complete_requirements}/{total_requirements} +
+
+
+
+
+
+ +
+

Implementation Breakdown

+ +
+
+
+
{implementation_and_test}
+
Implementation + Test
+
+
+
{implementation_only}
+
Implementation Only
+
+
+
{test_only}
+
Test Only
+
+
+
{exception_count}
+
Exception
+
+
+
{implication_count}
+
Implication
+
+
+
{no_implementation}
+
No Implementation
+
+
+ + + + +
diff --git a/test-server/specification b/test-server/specification new file mode 160000 index 00000000..1f1ae8bb --- /dev/null +++ b/test-server/specification @@ -0,0 +1 @@ +Subproject commit 1f1ae8bb2b7b082b87ffbf4916a9723e531b2052