diff --git a/.gitmodules b/.gitmodules index 162cd457..1e3a2922 100644 --- a/.gitmodules +++ b/.gitmodules @@ -27,9 +27,9 @@ 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 + path = test-server/net-v4-server/s3ec-net-v4-improved + url = https://github.com/aws/amazon-s3-encryption-client-dotnet.git + branch = dev [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 diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/BleichenbacherOracleTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/BleichenbacherOracleTests.java new file mode 100644 index 00000000..89128a50 --- /dev/null +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/BleichenbacherOracleTests.java @@ -0,0 +1,277 @@ +/* + * 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.assertNotEquals; +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.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterAll; +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.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.EncryptionAlgorithm; +import software.amazon.encryption.s3.model.GetObjectInput; +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.S3EncryptionClientError; + +/** + * Tests that verify the Bleichenbacher padding oracle does not exist across all + * RSA-supporting runtimes and commitment policy configurations. + */ +public class BleichenbacherOracleTests { + + private static KeyPair rsaKeyPair; + private static S3Client plaintextS3; + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final List createdKeys = Collections.synchronizedList(new ArrayList<>()); + + @BeforeAll + public static void setup() throws Exception { + validateServersRunning(); + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(2048); + rsaKeyPair = keyPairGen.generateKeyPair(); + plaintextS3 = S3Client.create(); + } + + @AfterAll + public static void cleanup() { + for (String key : createdKeys) { + try { + plaintextS3.deleteObject(b -> b.bucket(BUCKET).key(key)); + } catch (Exception ignored) { + } + } + } + + /** + * Represents a client configuration to test against. + */ + static class ConfigCase { + final String name; + final boolean legacyWrapping; + final CommitmentPolicy policy; + final EncryptionAlgorithm algo; + + ConfigCase(String name, boolean legacyWrapping, CommitmentPolicy policy, EncryptionAlgorithm algo) { + this.name = name; + this.legacyWrapping = legacyWrapping; + this.policy = policy; + this.algo = algo; + } + + @Override + public String toString() { return name; } + } + + /** + * Provides a matrix of (runtime x config) for parameterized tests. + * Transition versions only support FORBID_ENCRYPT_ALLOW_DECRYPT with GCM (no key commitment), + * so they get a reduced config set. + */ + static Stream rsaRuntimeAndPolicyMatrix() { + // All configs to test + List allConfigs = List.of( + new ConfigCase("GCM-forbid-encrypt-allow-decrypt", false, CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF), + new ConfigCase("KC-GCM-require-encrypt-allow-decrypt", false, CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT, EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY), + new ConfigCase("KC-GCM-require-encrypt-require-decrypt", false, CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY) + ); + + // Transition versions can only use FORBID_ENCRYPT_ALLOW_DECRYPT + List transitionConfigs = allConfigs.stream() + .filter(c -> c.policy == CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT) + .toList(); + + // For each RSA-capable runtime, pair it with the appropriate config set + return clientsRawRsaForTest() + .flatMap(langArg -> { + LanguageServerTarget lang = (LanguageServerTarget) langArg.get()[0]; + // Transition versions get fewer configs; improved versions get all + List configs = TRANSITION_VERSIONS.contains(lang.getLanguageName()) + ? transitionConfigs + : allConfigs; + return configs.stream().map(cfg -> Arguments.of(lang, cfg)); + }); + } + + /** + * For each (runtime, commitmentPolicy) combination: + * 1. Encrypt an object with RSA-OAEP + * 2. Copy it with V1 metadata (downgrade x-amz-key-v2 → x-amz-key) + * 3. Upload a second object with a known-valid PKCS#1v1.5 ciphertext in x-amz-key + * 4. Attempt to decrypt both with legacy disabled + * 5. Assert: the two produce the SAME error (proving the oracle is mitigated) + */ + @ParameterizedTest(name = "{0} / {1}") + @MethodSource("rsaRuntimeAndPolicyMatrix") + public void oracleDistinguishableErrorsMetaData(LanguageServerTarget language, ConfigCase configCase) throws Exception { + verifyNoOracle(language, configCase, "MetaData", null, this::uploadV1Object); + } + + /** + * Same as oracleDistinguishableErrorsMetaData but stores V1 metadata in an instruction file + * instead of object metadata. Verifies the oracle mitigation applies equally to + * the instruction file code path. + */ + @ParameterizedTest(name = "InstructionFile: {0} / {1}") + @MethodSource("rsaRuntimeAndPolicyMatrix") + public void oracleDistinguishableErrorsInstructionFile(LanguageServerTarget language, ConfigCase configCase) throws Exception { + if (INSTRUCTION_FILE_GET_UNSUPPORTED.contains(language.getLanguageName())) { + org.junit.jupiter.api.Assumptions.assumeTrue(false, language.getLanguageName() + " does not support instruction file get"); + } + verifyNoOracle(language, configCase, "InstructionFile", + InstructionFileConfig.builder().enableInstructionFilePutObject(true).build(), + this::uploadV1InstructionFileObject); + } + + @FunctionalInterface + private interface V1Uploader { + void upload(String key, byte[] body, String wrappedKey, String iv, String matdesc) throws Exception; + } + + private void verifyNoOracle(LanguageServerTarget language, ConfigCase configCase, String label, InstructionFileConfig instructionFileConfig, V1Uploader uploader) throws Exception { + S3ECTestServerClient client = testServerClientFor(language); + + KeyMaterial rsaKeyMaterial = KeyMaterial.builder() + .rsaKey(ByteBuffer.wrap(rsaKeyPair.getPrivate().getEncoded())) + .build(); + + S3ECConfig.Builder configBuilder = S3ECConfig.builder() + .enableLegacyWrappingAlgorithms(configCase.legacyWrapping) + .encryptionAlgorithm(configCase.algo) + .commitmentPolicy(configCase.policy) + .keyMaterial(rsaKeyMaterial); + if (instructionFileConfig != null) { + configBuilder.instructionFileConfig(instructionFileConfig); + } + S3ECConfig config = configBuilder.build(); + + String clientId = client.createClient(CreateClientInput.builder().config(config).build()).getClientId(); + + String suffix = language.getLanguageName() + "-" + configCase.name + "-" + label; + + // Encrypt with RSA-OAEP + final String originalKey = appendTestSuffix("bleichenbacher-original-" + suffix); + createdKeys.add(originalKey); + client.putObject(PutObjectInput.builder() + .clientID(clientId) + .bucket(BUCKET) + .key(originalKey) + .body(ByteBuffer.wrap("secret".getBytes(StandardCharsets.UTF_8))) + .build()); + + // Use random bytes for the invalid PKCS#1 padding + String wrappedKey = Base64.getEncoder().encodeToString(new byte[256]); + String iv = Base64.getEncoder().encodeToString(new byte[16]); + String matdesc = "{}"; + + // Download raw encrypted body + byte[] rawBody; + try (ResponseInputStream s3Object = plaintextS3.getObject(b -> b.bucket(BUCKET).key(originalKey))) { + rawBody = s3Object.readAllBytes(); + } + + // Upload with V1 wrapping with invalid PKCS#1 padding + final String invalidPaddingKey = appendTestSuffix("bleichenbacher-invalid-" + suffix); + createdKeys.add(invalidPaddingKey); + uploader.upload(invalidPaddingKey, rawBody, wrappedKey, iv, matdesc); + + // Upload with V1 wrapping (known VALID PKCS#1v1.5 ciphertext) + final String validPaddingKey = appendTestSuffix("bleichenbacher-valid-" + suffix); + createdKeys.add(validPaddingKey); + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, rsaKeyPair.getPublic()); + byte[] validPkcs1Ciphertext = cipher.doFinal(new byte[32]); + String validPkcs1Base64 = Base64.getEncoder().encodeToString(validPkcs1Ciphertext); + uploader.upload(validPaddingKey, rawBody, validPkcs1Base64, iv, matdesc); + + // Attempt decrypt of both — should get the same error + String errorInvalid = getDecryptError(client, clientId, invalidPaddingKey); + String errorValid = getDecryptError(client, clientId, validPaddingKey); + + System.out.printf("[BleichenbacherOracleTests][%s][%s][%s] Invalid padding error: %s%n", label, language.getLanguageName(), configCase.name, errorInvalid); + System.out.printf("[BleichenbacherOracleTests][%s][%s][%s] Valid padding error: %s%n", label, language.getLanguageName(), configCase.name, errorValid); + + assertNotEquals("NO_ERROR", errorInvalid, + String.format("[%s][%s][%s] Expected decryption to fail for invalid padding object but it succeeded", + label, language.getLanguageName(), configCase.name)); + assertNotEquals("NO_ERROR", errorValid, + String.format("[%s][%s][%s] Expected decryption to fail for valid padding object but it succeeded", + label, language.getLanguageName(), configCase.name)); + + assertEquals(errorInvalid, errorValid, + String.format("[%s][%s][%s] Errors differ for valid/invalid PKCS#1 padding — oracle still exists!", + label, language.getLanguageName(), configCase.name)); + System.out.printf("[BleichenbacherOracleTests][%s][%s][%s] PASSED — no oracle%n", label, language.getLanguageName(), configCase.name); + } + + private void uploadV1Object(String key, byte[] body, String wrappedKey, String iv, String matdesc) { + Map metadata = new HashMap<>(); + metadata.put("x-amz-key", wrappedKey); + metadata.put("x-amz-iv", iv); + metadata.put("x-amz-matdesc", matdesc != null ? matdesc : "{}"); + + plaintextS3.putObject(b -> b.bucket(BUCKET).key(key).metadata(metadata).contentLength((long) body.length), + RequestBody.fromBytes(body)); + } + + private String getDecryptError(S3ECTestServerClient client, String clientId, String key) { + try { + client.getObject(GetObjectInput.builder() + .clientID(clientId) + .bucket(BUCKET) + .key(key) + .build()); + return "NO_ERROR"; + } catch (S3EncryptionClientError e) { + return e.getMessage(); + } catch (Exception e) { + return "UNEXPECTED: " + e.getClass().getSimpleName() + ": " + e.getMessage(); + } + } + + private void uploadV1InstructionFileObject(String key, byte[] body, String wrappedKey, String iv, String matdesc) throws Exception { + // Upload body with NO encryption metadata in object metadata + plaintextS3.putObject(b -> b.bucket(BUCKET).key(key).contentLength((long) body.length), + RequestBody.fromBytes(body)); + + // Upload .instruction file with V1 metadata as JSON + Map instructionMap = new HashMap<>(); + instructionMap.put("x-amz-key", wrappedKey); + instructionMap.put("x-amz-iv", iv); + instructionMap.put("x-amz-matdesc", matdesc != null ? matdesc : "{}"); + String instructionJson = MAPPER.writeValueAsString(instructionMap); + plaintextS3.putObject(b -> b.bucket(BUCKET).key(key + ".instruction"), + RequestBody.fromString(instructionJson)); + createdKeys.add(key + ".instruction"); + } +} diff --git a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RsaV1LegacyDecryptTests.java b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RsaV1LegacyDecryptTests.java index eb8930e7..fcb0c3e8 100644 --- a/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RsaV1LegacyDecryptTests.java +++ b/test-server/java-tests/src/it/java/software/amazon/encryption/s3/RsaV1LegacyDecryptTests.java @@ -6,7 +6,8 @@ package software.amazon.encryption.s3; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; +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.charset.StandardCharsets; @@ -122,4 +123,53 @@ void canDecryptV1RsaObjectWithLegacyEnabled(LanguageServerTarget language, Strin assertEquals(INPUT, StandardCharsets.UTF_8.decode(output.getBody()).toString()); } + + @ParameterizedTest(name = "Encrypt: Java-V1-RSA, Decrypt: {0} / {1}") + @MethodSource("rsaRuntimeAndPolicyMatrix") + void cannotDecryptV1RsaObjectWithLegacyDisabled(LanguageServerTarget language, String configName, + CommitmentPolicy policy, EncryptionAlgorithm algo) { + S3ECTestServerClient client = testServerClientFor(language); + + KeyMaterial rsaKeyMaterial = KeyMaterial.builder() + .rsaKey(ByteBuffer.wrap(rsaKeyPair.getPrivate().getEncoded())) + .build(); + String clientId; + // Some languages use a single SecurityProfile toggle, so both must be false + if (LANGUAGES_WITH_SECURITY_PROFILE.contains(language.getLanguageName())) { + clientId = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(rsaKeyMaterial) + .commitmentPolicy(policy) + .encryptionAlgorithm(algo) + .enableLegacyUnauthenticatedModes(false) + .enableLegacyWrappingAlgorithms(false) + .build()) + .build()).getClientId(); + } else { + clientId = client.createClient(CreateClientInput.builder() + .config(S3ECConfig.builder() + .keyMaterial(rsaKeyMaterial) + .commitmentPolicy(policy) + .encryptionAlgorithm(algo) + .enableLegacyUnauthenticatedModes(true) + .enableLegacyWrappingAlgorithms(false) + .build()) + .build()).getClientId(); + } + + try { + client.getObject(GetObjectInput.builder() + .clientID(clientId) + .bucket(BUCKET) + .key(v1ObjectKey) + .build()); + fail("Expected exception!"); + } catch (S3EncryptionClientError e) { + if (LANGUAGES_WITH_SECURITY_PROFILE.contains(language.getLanguageName())) { + 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 { + assertTrue(e.getMessage().contains("Enable legacy wrapping algorithms to use legacy key wrapping algorithm: RSA"), "Actual error: " + e.getMessage()); + } + } + } } 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 index 5f4ce9d6..180128a6 100644 --- 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 @@ -150,6 +150,22 @@ public class TestUtils { PHP_V3 ); + // Languages that use a single SecurityProfile toggle instead of separate + // enableLegacyUnauthenticatedModes / enableLegacyWrappingAlgorithms flags. + public static final Set LANGUAGES_WITH_SECURITY_PROFILE = + Set.of( + RUBY_V2_TRANSITION, + RUBY_V3, + PHP_V2_TRANSITION, + PHP_V3, + CPP_V2_TRANSITION, + CPP_V3, + GO_V3_TRANSITION, + GO_V4, + NET_V3_TRANSITION, + NET_V4 + ); + public static final Set TRANSITION_VERSIONS = Set.of( JAVA_V3_TRANSITION, diff --git a/test-server/net-v3-transition-server/s3ec-v3-transition-branch b/test-server/net-v3-transition-server/s3ec-v3-transition-branch index 7a552940..c2c19c7f 160000 --- a/test-server/net-v3-transition-server/s3ec-v3-transition-branch +++ b/test-server/net-v3-transition-server/s3ec-v3-transition-branch @@ -1 +1 @@ -Subproject commit 7a55294068bb3bb7f96226efd6d9edcd1057184b +Subproject commit c2c19c7f09b6dfb5eb4f9ca7a42da443a0c54f08 diff --git a/test-server/net-v4-server/s3ec-net-v4-improved b/test-server/net-v4-server/s3ec-net-v4-improved index 9b628b06..2cc436f4 160000 --- a/test-server/net-v4-server/s3ec-net-v4-improved +++ b/test-server/net-v4-server/s3ec-net-v4-improved @@ -1 +1 @@ -Subproject commit 9b628b06e5c1bf12696c752afb2631c38cae11f9 +Subproject commit 2cc436f49a5a07c220c6a627da1b9831a3354836