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 5b9eb535..b6ecc6a6 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 @@ -54,6 +54,14 @@ import com.amazonaws.services.s3.model.KMSEncryptionMaterialsProvider; public class RoundTripTests { + private static final String JAVA_V3 = "Java-V3"; + private static final String PYTHON_V3 = "Python-V3"; + private static final String GO_V3 = "Go-V3"; + private static final String NET_V2 = "NET-V2"; + private static final String NET_V3 = "NET-V3"; + private static final String PHP_V2 = "PHP-V2"; + private static final String PHP_V3 = "PHP-V3"; + private static final List serverList; private static final Map serverMap; @@ -65,26 +73,39 @@ public class RoundTripTests { static { serverList = new ArrayList<>(14); - serverList.add(new LanguageServerTarget("Java-V3", "8080")); - serverList.add(new LanguageServerTarget("Python-V3", "8081")); - serverList.add(new LanguageServerTarget("Go-V3", "8082")); - serverList.add(new LanguageServerTarget("PHP-V2", "8087")); - serverList.add(new LanguageServerTarget("PHP-V3", "8093")); + serverList.add(new LanguageServerTarget(JAVA_V3, "8080")); + serverList.add(new LanguageServerTarget(PYTHON_V3, "8081")); + serverList.add(new LanguageServerTarget(GO_V3, "8082")); + serverList.add(new LanguageServerTarget(NET_V2, "8083")); + serverList.add(new LanguageServerTarget(NET_V3, "8084")); + serverList.add(new LanguageServerTarget(PHP_V2, "8087")); + serverList.add(new LanguageServerTarget(PHP_V3, "8093")); serverMap = new HashMap<>(14); - serverMap.put("Java-V3", new LanguageServerTarget("Java-V3", "8080")); - serverMap.put("Python-V3", new LanguageServerTarget("Python-V3", "8081")); - serverMap.put("Go-V3", new LanguageServerTarget("Go-V3", "8082")); - serverMap.put("PHP-V2", new LanguageServerTarget("PHP-V2", "8087")); - serverMap.put("PHP-V3", new LanguageServerTarget("PHP-V3", "8093")); + serverMap.put(JAVA_V3, new LanguageServerTarget(JAVA_V3, "8080")); + serverMap.put(PYTHON_V3, new LanguageServerTarget(PYTHON_V3, "8081")); + serverMap.put(GO_V3, new LanguageServerTarget(GO_V3, "8082")); + serverMap.put(NET_V2, new LanguageServerTarget(NET_V2, "8083")); + serverMap.put(NET_V3, new LanguageServerTarget(NET_V3, "8084")); + serverMap.put(PHP_V2, new LanguageServerTarget(PHP_V2, "8087")); + serverMap.put(PHP_V3, new LanguageServerTarget(PHP_V3, "8093")); } - // These S3EC implementations do not validate encryption context provided to getObject (i.e. on decrypt). + // Encryption context validation behavior varies by implementation: + // - Go: Does not validate encryption context on decrypt operations + // - .NET: Only validates against encryption context stored in the object metadata // If the encryption context provided to getObject does not match the encryption context on the stored object, // these implementations will not raise an error as expected. // For now, skip tests that expect encryption context validation on decrypt. private static final Set ENCRYPTION_CONTEXT_ON_DECRYPT_UNSUPPORTED = - Set.of("Go-V3", "PHP-V2", "PHP-V3"); + Set.of(GO_V3, PHP_V2, PHP_V3, NET_V2, NET_V3); + + // S3EC .NET implementations does not accept encryption context (EC) during putObject operations. + // These tests are not configured to pass encryption context at client level but at encrypt, + // So, for .NET EC is not passed. + // For now, skip tests that expect encryption context validation on decrypt. + private static final Set ENCRYPTION_CONTEXT_ON_ENCRYPT_UNSUPPORTED = + Set.of(NET_V2, NET_V3); static public class LanguageServerTarget { public String getLanguageName() { @@ -226,6 +247,9 @@ public void crossLanguageTestKms(LanguageServerTarget encLang, LanguageServerTar @ParameterizedTest(name = "{displayName} for Encrypt: {0}, Decrypt: {1}") @MethodSource("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 input = "simple-test-input"; @@ -273,6 +297,9 @@ public void crossLanguageTestKmsWithSubsetEncCtxFails(LanguageServerTarget encLa 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-subset-fails" + encLang; final String input = "simple-test-input"; @@ -498,7 +525,13 @@ 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.equals(NET_V3) || language.equals(NET_V2)) { + assertTrue(e.getMessage().contains( + "The requested object is encrypted with V1 encryption schemas that have been disabled by client configuration V2." + )); + } else { + assertTrue(e.getMessage().contains("Enable legacy wrapping algorithms to use legacy key wrapping algorithm: kms")); + } } } diff --git a/test-server/net-v2-v3-server/.gitignore b/test-server/net-v2-v3-server/.gitignore new file mode 100644 index 00000000..4c20cbc8 --- /dev/null +++ b/test-server/net-v2-v3-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-v2-v3-server/Controllers/ClientController.cs b/test-server/net-v2-v3-server/Controllers/ClientController.cs new file mode 100644 index 00000000..01bf610c --- /dev/null +++ b/test-server/net-v2-v3-server/Controllers/ClientController.cs @@ -0,0 +1,72 @@ +using System.Text.Json; +using Amazon.Extensions.S3.Encryption; +using Amazon.Extensions.S3.Encryption.Primitives; +using Microsoft.AspNetCore.Mvc; +using NetV2V3Server.Models; +using NetV2V3Server.Services; + +namespace NetV2V3Server.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" }); + if (request.Config.KeyMaterial.RsaKey != null) + return StatusCode(501, new GenericServerError { Message = "RsaKey not supported" }); + if (request.Config.KeyMaterial.AesKey != null) + return StatusCode(501, new GenericServerError { Message = "AesKey not supported" }); + + var kmsKeyId = request.Config.KeyMaterial.KmsKeyId; + var enableLegacyUnauthenticatedModes = request.Config.EnableLegacyUnauthenticatedModes; + var enableLegacyWrappingAlgorithms = request.Config.EnableLegacyWrappingAlgorithms; + + try + { + // 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 encryptionMaterial = new EncryptionMaterialsV2(kmsKeyId, KmsType.KmsContext, encryptionContext); + logger.LogInformation( + "Created EncryptionMaterialsV2: KMS={KmsKeyId}", + kmsKeyId); + // SecurityProfile V2AndLegacy can decrypt from legacy S3EC but V2 cannot + var enableLegacyMode = enableLegacyUnauthenticatedModes || enableLegacyWrappingAlgorithms; + var securityProfile = enableLegacyMode ? SecurityProfile.V2AndLegacy : SecurityProfile.V2; + + logger.LogInformation("Created securityProfile= {securityProfile}", securityProfile.ToString()); + + var configuration = new AmazonS3CryptoConfigurationV2(securityProfile); + // 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("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, "Failed to create S3EC client"); + return StatusCode(500, new S3EncryptionClientError + { + Message = $"Failed to create client: {ex.Message}" + }); + } + } +} \ No newline at end of file diff --git a/test-server/net-v2-v3-server/Controllers/ObjectController.cs b/test-server/net-v2-v3-server/Controllers/ObjectController.cs new file mode 100644 index 00000000..bf1842ae --- /dev/null +++ b/test-server/net-v2-v3-server/Controllers/ObjectController.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using Amazon.S3.Model; +using Microsoft.AspNetCore.Mvc; +using NetV2V3Server.Models; +using NetV2V3Server.Services; + +namespace NetV2V3Server.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 = "ClientID header is required" }); + + var client = clientCacheService.GetClient(clientId); + if (client == null) + return NotFound(new GenericServerError { Message = $"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( + "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, "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("Starting GetObject"); + var clientId = Request.Headers["clientId"].FirstOrDefault(); + if (string.IsNullOrEmpty(clientId)) + return BadRequest(new GenericServerError { Message = "ClientID header is required" }); + + var client = clientCacheService.GetClient(clientId); + if (client == null) + return NotFound(new GenericServerError { Message = $"No client found for ClientID: {clientId}" }); + + try + { + var getRequest = new GetObjectRequest + { + BucketName = bucket, + Key = key + }; + var response = await client.GetObjectAsync(getRequest); + logger.LogInformation("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, "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-v2-v3-server/Makefile b/test-server/net-v2-v3-server/Makefile new file mode 100644 index 00000000..f5b18688 --- /dev/null +++ b/test-server/net-v2-v3-server/Makefile @@ -0,0 +1,55 @@ +# Makefile for S3 Encryption Client .NET Testing + +.PHONY: start-server stop-server wait-for-server + +PID_FILE_NET_V2 := net-v2-server.pid +PID_FILE_NET_V3 := net-v3-server.pid +PORT_NET_V2 := 8083 +PORT_NET_V3 := 8084 + +start-server: + $(MAKE) start-net-v2-server; \ + $(MAKE) start-net-v3-server; + +stop-server: + @if [ -f $(PID_FILE_NET_V2) ]; then \ + kill $$(cat $(PID_FILE_NET_V2)) 2>/dev/null || true; \ + rm $(PID_FILE_NET_V2); \ + fi + @if [ -f $(PID_FILE_NET_V3) ]; then \ + kill $$(cat $(PID_FILE_NET_V3)) 2>/dev/null || true; \ + rm $(PID_FILE_NET_V3); \ + fi + +# Start .NET V2 server in background +# This builds first into bin/v2 and runs through dll +# to avoid simultaneous dotnet run conflict +start-net-v2-server: + @echo "Starting .NET 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" \ + rm -rf obj/v2 bin/v2 && \ + dotnet build -p:S3EncryptionVersion=v2 -o bin/v2 -p:BaseIntermediateOutputPath=obj/v2/ && \ + dotnet bin/v2/NetV2V3Server.dll > net-v2-server.log 2>&1 & echo $$! > net-v2-server.pid + @echo ".NET V2 server starting..." + + +# Start .NET V3 server in background +# This builds first into bin/v3 and runs through dll +# to avoid simultaneous dotnet run conflict +start-net-v3-server: + @echo "Starting .NET 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" \ + rm -rf obj/v3 bin/v3 && \ + dotnet build -p:S3EncryptionVersion=v3 -o bin/v3 -p:BaseIntermediateOutputPath=obj/v3/ && \ + dotnet bin/v3/NetV2V3Server.dll > net-v3-server.log 2>&1 & echo $$! > net-v3-server.pid + @echo ".NET V3 server starting..." + +wait-for-server: + $(MAKE) -C .. wait-for-port PORT=$(PORT_NET_V2) \ + $(MAKE) -C .. wait-for-port PORT=$(PORT_NET_V3) \ No newline at end of file diff --git a/test-server/net-v2-v3-server/Models/ClientRequest.cs b/test-server/net-v2-v3-server/Models/ClientRequest.cs new file mode 100644 index 00000000..6882b4f9 --- /dev/null +++ b/test-server/net-v2-v3-server/Models/ClientRequest.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace NetV2V3Server.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(); +} + +public class KeyMaterial +{ + public byte[]? RsaKey { get; set; } + public byte[]? AesKey { get; set; } + + [Required] + public string KmsKeyId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/test-server/net-v2-v3-server/Models/ClientResponse.cs b/test-server/net-v2-v3-server/Models/ClientResponse.cs new file mode 100644 index 00000000..1e029ef7 --- /dev/null +++ b/test-server/net-v2-v3-server/Models/ClientResponse.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace NetV2V3Server.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-v2-v3-server/Models/ErrorModels.cs b/test-server/net-v2-v3-server/Models/ErrorModels.cs new file mode 100644 index 00000000..af1646e7 --- /dev/null +++ b/test-server/net-v2-v3-server/Models/ErrorModels.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace NetV2V3Server.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-v2-v3-server/NetV2V3Server.csproj b/test-server/net-v2-v3-server/NetV2V3Server.csproj new file mode 100644 index 00000000..a803a838 --- /dev/null +++ b/test-server/net-v2-v3-server/NetV2V3Server.csproj @@ -0,0 +1,34 @@ + + + + net8.0 + enable + enable + + + + false + + + + S3EC_V2 + + + + S3EC_V3 + + + + + + + + + + + + + + + + diff --git a/test-server/net-v2-v3-server/Program.cs b/test-server/net-v2-v3-server/Program.cs new file mode 100644 index 00000000..1b77de5d --- /dev/null +++ b/test-server/net-v2-v3-server/Program.cs @@ -0,0 +1,21 @@ +using NetV2V3Server.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddSingleton(); + +#if S3EC_V2 +const int port = 8083; +#else +const int port = 8084; +#endif + +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-v2-v3-server/README.md b/test-server/net-v2-v3-server/README.md new file mode 100644 index 00000000..9463d8b5 --- /dev/null +++ b/test-server/net-v2-v3-server/README.md @@ -0,0 +1,72 @@ +# Net-V2-V3-Server + +A .NET test server for Amazon S3 encryption client .NET v2 and v3. + +## Project Structure + +``` +net-v2-v3-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":{"enableLegacyUnauthenticatedModes":true,"enableLegacyWrappingAlgorithms":true,"keyMaterial":{"kmsKeyId":"arn:aws:kms:us-west-2:370957321024:alias/S3EC-Test-Server-Github-KMS-Key"}, "encryptionContext": {"abc": "b"}}}' \ + http://localhost:8083/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-v2-v3-server/Services/ClientCacheService.cs b/test-server/net-v2-v3-server/Services/ClientCacheService.cs new file mode 100644 index 00000000..d8239c9b --- /dev/null +++ b/test-server/net-v2-v3-server/Services/ClientCacheService.cs @@ -0,0 +1,28 @@ +using Amazon.Extensions.S3.Encryption; +using System.Collections.Concurrent; + +namespace NetV2V3Server.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; + } +}