Skip to content

Commit 15f421f

Browse files
chore: add test server test for RSA wrapping keys (#195)
1 parent 863c05e commit 15f421f

6 files changed

Lines changed: 349 additions & 6 deletions

File tree

‎.gitmodules‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727
url = git@github.com:awslabs/private-aws-encryption-sdk-specification-staging.git
2828
branch = fire-egg-staging
2929
[submodule "test-server/net-v4-server/s3ec-net-v4-improved"]
30-
path = test-server/net-v4-server/s3ec-net-v4-improved
31-
url = https://github.com/aws/amazon-s3-encryption-client-dotnet.git
32-
branch = main
30+
path = test-server/net-v4-server/s3ec-net-v4-improved
31+
url = https://github.com/aws/amazon-s3-encryption-client-dotnet.git
32+
branch = dev
3333
[submodule "test-server/go-v3-transition-server/local-go-s3ec"]
3434
path = test-server/go-v3-transition-server/local-go-s3ec
3535
url = https://github.com/aws/amazon-s3-encryption-client-go
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.encryption.s3;
7+
8+
import static org.junit.jupiter.api.Assertions.assertEquals;
9+
import static org.junit.jupiter.api.Assertions.assertNotEquals;
10+
import static software.amazon.encryption.s3.TestUtils.*;
11+
12+
import java.nio.ByteBuffer;
13+
import java.nio.charset.StandardCharsets;
14+
import java.security.KeyPair;
15+
import java.security.KeyPairGenerator;
16+
import java.util.ArrayList;
17+
import java.util.Base64;
18+
import java.util.Collections;
19+
import java.util.HashMap;
20+
import java.util.List;
21+
import java.util.Map;
22+
import java.util.stream.Stream;
23+
24+
import software.amazon.awssdk.core.ResponseInputStream;
25+
import software.amazon.awssdk.core.sync.RequestBody;
26+
import software.amazon.awssdk.services.s3.S3Client;
27+
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
28+
import com.fasterxml.jackson.databind.ObjectMapper;
29+
import org.junit.jupiter.api.AfterAll;
30+
import org.junit.jupiter.api.BeforeAll;
31+
import org.junit.jupiter.params.ParameterizedTest;
32+
import org.junit.jupiter.params.provider.Arguments;
33+
import org.junit.jupiter.params.provider.MethodSource;
34+
import software.amazon.encryption.s3.TestUtils.LanguageServerTarget;
35+
import software.amazon.encryption.s3.client.S3ECTestServerClient;
36+
import software.amazon.encryption.s3.model.CommitmentPolicy;
37+
import software.amazon.encryption.s3.model.CreateClientInput;
38+
import software.amazon.encryption.s3.model.EncryptionAlgorithm;
39+
import software.amazon.encryption.s3.model.GetObjectInput;
40+
import software.amazon.encryption.s3.model.InstructionFileConfig;
41+
import software.amazon.encryption.s3.model.KeyMaterial;
42+
import software.amazon.encryption.s3.model.PutObjectInput;
43+
import software.amazon.encryption.s3.model.S3ECConfig;
44+
import software.amazon.encryption.s3.model.S3EncryptionClientError;
45+
46+
/**
47+
* Tests that verify the Bleichenbacher padding oracle does not exist across all
48+
* RSA-supporting runtimes and commitment policy configurations.
49+
*/
50+
public class BleichenbacherOracleTests {
51+
52+
private static KeyPair rsaKeyPair;
53+
private static S3Client plaintextS3;
54+
private static final ObjectMapper MAPPER = new ObjectMapper();
55+
private static final List<String> createdKeys = Collections.synchronizedList(new ArrayList<>());
56+
57+
@BeforeAll
58+
public static void setup() throws Exception {
59+
validateServersRunning();
60+
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
61+
keyPairGen.initialize(2048);
62+
rsaKeyPair = keyPairGen.generateKeyPair();
63+
plaintextS3 = S3Client.create();
64+
}
65+
66+
@AfterAll
67+
public static void cleanup() {
68+
for (String key : createdKeys) {
69+
try {
70+
plaintextS3.deleteObject(b -> b.bucket(BUCKET).key(key));
71+
} catch (Exception ignored) {
72+
}
73+
}
74+
}
75+
76+
/**
77+
* Represents a client configuration to test against.
78+
*/
79+
static class ConfigCase {
80+
final String name;
81+
final boolean legacyWrapping;
82+
final CommitmentPolicy policy;
83+
final EncryptionAlgorithm algo;
84+
85+
ConfigCase(String name, boolean legacyWrapping, CommitmentPolicy policy, EncryptionAlgorithm algo) {
86+
this.name = name;
87+
this.legacyWrapping = legacyWrapping;
88+
this.policy = policy;
89+
this.algo = algo;
90+
}
91+
92+
@Override
93+
public String toString() { return name; }
94+
}
95+
96+
/**
97+
* Provides a matrix of (runtime x config) for parameterized tests.
98+
* Transition versions only support FORBID_ENCRYPT_ALLOW_DECRYPT with GCM (no key commitment),
99+
* so they get a reduced config set.
100+
*/
101+
static Stream<Arguments> rsaRuntimeAndPolicyMatrix() {
102+
// All configs to test
103+
List<ConfigCase> allConfigs = List.of(
104+
new ConfigCase("GCM-forbid-encrypt-allow-decrypt", false, CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT, EncryptionAlgorithm.ALG_AES_256_GCM_IV12_TAG16_NO_KDF),
105+
new ConfigCase("KC-GCM-require-encrypt-allow-decrypt", false, CommitmentPolicy.REQUIRE_ENCRYPT_ALLOW_DECRYPT, EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY),
106+
new ConfigCase("KC-GCM-require-encrypt-require-decrypt", false, CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT, EncryptionAlgorithm.ALG_AES_256_GCM_HKDF_SHA512_COMMIT_KEY)
107+
);
108+
109+
// Transition versions can only use FORBID_ENCRYPT_ALLOW_DECRYPT
110+
List<ConfigCase> transitionConfigs = allConfigs.stream()
111+
.filter(c -> c.policy == CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT)
112+
.toList();
113+
114+
// For each RSA-capable runtime, pair it with the appropriate config set
115+
return clientsRawRsaForTest()
116+
.flatMap(langArg -> {
117+
LanguageServerTarget lang = (LanguageServerTarget) langArg.get()[0];
118+
// Transition versions get fewer configs; improved versions get all
119+
List<ConfigCase> configs = TRANSITION_VERSIONS.contains(lang.getLanguageName())
120+
? transitionConfigs
121+
: allConfigs;
122+
return configs.stream().map(cfg -> Arguments.of(lang, cfg));
123+
});
124+
}
125+
126+
/**
127+
* For each (runtime, commitmentPolicy) combination:
128+
* 1. Encrypt an object with RSA-OAEP
129+
* 2. Copy it with V1 metadata (downgrade x-amz-key-v2 → x-amz-key)
130+
* 3. Upload a second object with a known-valid PKCS#1v1.5 ciphertext in x-amz-key
131+
* 4. Attempt to decrypt both with legacy disabled
132+
* 5. Assert: the two produce the SAME error (proving the oracle is mitigated)
133+
*/
134+
@ParameterizedTest(name = "{0} / {1}")
135+
@MethodSource("rsaRuntimeAndPolicyMatrix")
136+
public void oracleDistinguishableErrorsMetaData(LanguageServerTarget language, ConfigCase configCase) throws Exception {
137+
verifyNoOracle(language, configCase, "MetaData", null, this::uploadV1Object);
138+
}
139+
140+
/**
141+
* Same as oracleDistinguishableErrorsMetaData but stores V1 metadata in an instruction file
142+
* instead of object metadata. Verifies the oracle mitigation applies equally to
143+
* the instruction file code path.
144+
*/
145+
@ParameterizedTest(name = "InstructionFile: {0} / {1}")
146+
@MethodSource("rsaRuntimeAndPolicyMatrix")
147+
public void oracleDistinguishableErrorsInstructionFile(LanguageServerTarget language, ConfigCase configCase) throws Exception {
148+
if (INSTRUCTION_FILE_GET_UNSUPPORTED.contains(language.getLanguageName())) {
149+
org.junit.jupiter.api.Assumptions.assumeTrue(false, language.getLanguageName() + " does not support instruction file get");
150+
}
151+
verifyNoOracle(language, configCase, "InstructionFile",
152+
InstructionFileConfig.builder().enableInstructionFilePutObject(true).build(),
153+
this::uploadV1InstructionFileObject);
154+
}
155+
156+
@FunctionalInterface
157+
private interface V1Uploader {
158+
void upload(String key, byte[] body, String wrappedKey, String iv, String matdesc) throws Exception;
159+
}
160+
161+
private void verifyNoOracle(LanguageServerTarget language, ConfigCase configCase, String label, InstructionFileConfig instructionFileConfig, V1Uploader uploader) throws Exception {
162+
S3ECTestServerClient client = testServerClientFor(language);
163+
164+
KeyMaterial rsaKeyMaterial = KeyMaterial.builder()
165+
.rsaKey(ByteBuffer.wrap(rsaKeyPair.getPrivate().getEncoded()))
166+
.build();
167+
168+
S3ECConfig.Builder configBuilder = S3ECConfig.builder()
169+
.enableLegacyWrappingAlgorithms(configCase.legacyWrapping)
170+
.encryptionAlgorithm(configCase.algo)
171+
.commitmentPolicy(configCase.policy)
172+
.keyMaterial(rsaKeyMaterial);
173+
if (instructionFileConfig != null) {
174+
configBuilder.instructionFileConfig(instructionFileConfig);
175+
}
176+
S3ECConfig config = configBuilder.build();
177+
178+
String clientId = client.createClient(CreateClientInput.builder().config(config).build()).getClientId();
179+
180+
String suffix = language.getLanguageName() + "-" + configCase.name + "-" + label;
181+
182+
// Encrypt with RSA-OAEP
183+
final String originalKey = appendTestSuffix("bleichenbacher-original-" + suffix);
184+
createdKeys.add(originalKey);
185+
client.putObject(PutObjectInput.builder()
186+
.clientID(clientId)
187+
.bucket(BUCKET)
188+
.key(originalKey)
189+
.body(ByteBuffer.wrap("secret".getBytes(StandardCharsets.UTF_8)))
190+
.build());
191+
192+
// Use random bytes for the invalid PKCS#1 padding
193+
String wrappedKey = Base64.getEncoder().encodeToString(new byte[256]);
194+
String iv = Base64.getEncoder().encodeToString(new byte[16]);
195+
String matdesc = "{}";
196+
197+
// Download raw encrypted body
198+
byte[] rawBody;
199+
try (ResponseInputStream<GetObjectResponse> s3Object = plaintextS3.getObject(b -> b.bucket(BUCKET).key(originalKey))) {
200+
rawBody = s3Object.readAllBytes();
201+
}
202+
203+
// Upload with V1 wrapping with invalid PKCS#1 padding
204+
final String invalidPaddingKey = appendTestSuffix("bleichenbacher-invalid-" + suffix);
205+
createdKeys.add(invalidPaddingKey);
206+
uploader.upload(invalidPaddingKey, rawBody, wrappedKey, iv, matdesc);
207+
208+
// Upload with V1 wrapping (known VALID PKCS#1v1.5 ciphertext)
209+
final String validPaddingKey = appendTestSuffix("bleichenbacher-valid-" + suffix);
210+
createdKeys.add(validPaddingKey);
211+
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance("RSA/ECB/PKCS1Padding");
212+
cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, rsaKeyPair.getPublic());
213+
byte[] validPkcs1Ciphertext = cipher.doFinal(new byte[32]);
214+
String validPkcs1Base64 = Base64.getEncoder().encodeToString(validPkcs1Ciphertext);
215+
uploader.upload(validPaddingKey, rawBody, validPkcs1Base64, iv, matdesc);
216+
217+
// Attempt decrypt of both — should get the same error
218+
String errorInvalid = getDecryptError(client, clientId, invalidPaddingKey);
219+
String errorValid = getDecryptError(client, clientId, validPaddingKey);
220+
221+
System.out.printf("[BleichenbacherOracleTests][%s][%s][%s] Invalid padding error: %s%n", label, language.getLanguageName(), configCase.name, errorInvalid);
222+
System.out.printf("[BleichenbacherOracleTests][%s][%s][%s] Valid padding error: %s%n", label, language.getLanguageName(), configCase.name, errorValid);
223+
224+
assertNotEquals("NO_ERROR", errorInvalid,
225+
String.format("[%s][%s][%s] Expected decryption to fail for invalid padding object but it succeeded",
226+
label, language.getLanguageName(), configCase.name));
227+
assertNotEquals("NO_ERROR", errorValid,
228+
String.format("[%s][%s][%s] Expected decryption to fail for valid padding object but it succeeded",
229+
label, language.getLanguageName(), configCase.name));
230+
231+
assertEquals(errorInvalid, errorValid,
232+
String.format("[%s][%s][%s] Errors differ for valid/invalid PKCS#1 padding — oracle still exists!",
233+
label, language.getLanguageName(), configCase.name));
234+
System.out.printf("[BleichenbacherOracleTests][%s][%s][%s] PASSED — no oracle%n", label, language.getLanguageName(), configCase.name);
235+
}
236+
237+
private void uploadV1Object(String key, byte[] body, String wrappedKey, String iv, String matdesc) {
238+
Map<String, String> metadata = new HashMap<>();
239+
metadata.put("x-amz-key", wrappedKey);
240+
metadata.put("x-amz-iv", iv);
241+
metadata.put("x-amz-matdesc", matdesc != null ? matdesc : "{}");
242+
243+
plaintextS3.putObject(b -> b.bucket(BUCKET).key(key).metadata(metadata).contentLength((long) body.length),
244+
RequestBody.fromBytes(body));
245+
}
246+
247+
private String getDecryptError(S3ECTestServerClient client, String clientId, String key) {
248+
try {
249+
client.getObject(GetObjectInput.builder()
250+
.clientID(clientId)
251+
.bucket(BUCKET)
252+
.key(key)
253+
.build());
254+
return "NO_ERROR";
255+
} catch (S3EncryptionClientError e) {
256+
return e.getMessage();
257+
} catch (Exception e) {
258+
return "UNEXPECTED: " + e.getClass().getSimpleName() + ": " + e.getMessage();
259+
}
260+
}
261+
262+
private void uploadV1InstructionFileObject(String key, byte[] body, String wrappedKey, String iv, String matdesc) throws Exception {
263+
// Upload body with NO encryption metadata in object metadata
264+
plaintextS3.putObject(b -> b.bucket(BUCKET).key(key).contentLength((long) body.length),
265+
RequestBody.fromBytes(body));
266+
267+
// Upload .instruction file with V1 metadata as JSON
268+
Map<String, String> instructionMap = new HashMap<>();
269+
instructionMap.put("x-amz-key", wrappedKey);
270+
instructionMap.put("x-amz-iv", iv);
271+
instructionMap.put("x-amz-matdesc", matdesc != null ? matdesc : "{}");
272+
String instructionJson = MAPPER.writeValueAsString(instructionMap);
273+
plaintextS3.putObject(b -> b.bucket(BUCKET).key(key + ".instruction"),
274+
RequestBody.fromString(instructionJson));
275+
createdKeys.add(key + ".instruction");
276+
}
277+
}

‎test-server/java-tests/src/it/java/software/amazon/encryption/s3/RsaV1LegacyDecryptTests.java‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
package software.amazon.encryption.s3;
77

88
import static org.junit.jupiter.api.Assertions.assertEquals;
9-
import static org.junit.jupiter.api.Assertions.assertThrows;
9+
import static org.junit.jupiter.api.Assertions.assertTrue;
10+
import static org.junit.jupiter.api.Assertions.fail;
1011
import static software.amazon.encryption.s3.TestUtils.*;
1112

1213
import java.nio.charset.StandardCharsets;
@@ -122,4 +123,53 @@ void canDecryptV1RsaObjectWithLegacyEnabled(LanguageServerTarget language, Strin
122123

123124
assertEquals(INPUT, StandardCharsets.UTF_8.decode(output.getBody()).toString());
124125
}
126+
127+
@ParameterizedTest(name = "Encrypt: Java-V1-RSA, Decrypt: {0} / {1}")
128+
@MethodSource("rsaRuntimeAndPolicyMatrix")
129+
void cannotDecryptV1RsaObjectWithLegacyDisabled(LanguageServerTarget language, String configName,
130+
CommitmentPolicy policy, EncryptionAlgorithm algo) {
131+
S3ECTestServerClient client = testServerClientFor(language);
132+
133+
KeyMaterial rsaKeyMaterial = KeyMaterial.builder()
134+
.rsaKey(ByteBuffer.wrap(rsaKeyPair.getPrivate().getEncoded()))
135+
.build();
136+
String clientId;
137+
// Some languages use a single SecurityProfile toggle, so both must be false
138+
if (LANGUAGES_WITH_SECURITY_PROFILE.contains(language.getLanguageName())) {
139+
clientId = client.createClient(CreateClientInput.builder()
140+
.config(S3ECConfig.builder()
141+
.keyMaterial(rsaKeyMaterial)
142+
.commitmentPolicy(policy)
143+
.encryptionAlgorithm(algo)
144+
.enableLegacyUnauthenticatedModes(false)
145+
.enableLegacyWrappingAlgorithms(false)
146+
.build())
147+
.build()).getClientId();
148+
} else {
149+
clientId = client.createClient(CreateClientInput.builder()
150+
.config(S3ECConfig.builder()
151+
.keyMaterial(rsaKeyMaterial)
152+
.commitmentPolicy(policy)
153+
.encryptionAlgorithm(algo)
154+
.enableLegacyUnauthenticatedModes(true)
155+
.enableLegacyWrappingAlgorithms(false)
156+
.build())
157+
.build()).getClientId();
158+
}
159+
160+
try {
161+
client.getObject(GetObjectInput.builder()
162+
.clientID(clientId)
163+
.bucket(BUCKET)
164+
.key(v1ObjectKey)
165+
.build());
166+
fail("Expected exception!");
167+
} catch (S3EncryptionClientError e) {
168+
if (LANGUAGES_WITH_SECURITY_PROFILE.contains(language.getLanguageName())) {
169+
assertTrue(e.getMessage().contains("The requested object is encrypted with V1 encryption schemas that have been disabled by client configuration"), "Actual error: " + e.getMessage());
170+
} else {
171+
assertTrue(e.getMessage().contains("Enable legacy wrapping algorithms to use legacy key wrapping algorithm: RSA"), "Actual error: " + e.getMessage());
172+
}
173+
}
174+
}
125175
}

‎test-server/java-tests/src/it/java/software/amazon/encryption/s3/TestUtils.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,22 @@ public class TestUtils {
150150
PHP_V3
151151
);
152152

153+
// Languages that use a single SecurityProfile toggle instead of separate
154+
// enableLegacyUnauthenticatedModes / enableLegacyWrappingAlgorithms flags.
155+
public static final Set<String> LANGUAGES_WITH_SECURITY_PROFILE =
156+
Set.of(
157+
RUBY_V2_TRANSITION,
158+
RUBY_V3,
159+
PHP_V2_TRANSITION,
160+
PHP_V3,
161+
CPP_V2_TRANSITION,
162+
CPP_V3,
163+
GO_V3_TRANSITION,
164+
GO_V4,
165+
NET_V3_TRANSITION,
166+
NET_V4
167+
);
168+
153169
public static final Set<String> TRANSITION_VERSIONS =
154170
Set.of(
155171
JAVA_V3_TRANSITION,

0 commit comments

Comments
 (0)