diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj
index 0351a4fe8..6881d71c4 100644
--- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj
+++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj
@@ -5,12 +5,12 @@
GoDaddy.Asherah.AppEncryption.IntegrationTests
true
true
- Recommended
+ Minimum
true
- $(NoWarn);CA1873
+ $(NoWarn);CS0618;CA1816;CA1873
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/GlobalSuppressions.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/GlobalSuppressions.cs
deleted file mode 100644
index 0d2a7a978..000000000
--- a/csharp/AppEncryption/AppEncryption.IntegrationTests/GlobalSuppressions.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-// This file is used by Code Analysis to maintain SuppressMessage
-// attributes that are applied to this project.
-// Project-level suppressions either have no target or are given
-// a specific target and scoped to a namespace, type, member, etc.
-
-using System.Diagnostics.CodeAnalysis;
-
-[assembly: SuppressMessage("Performance", "CA1848:Use LoggerMessage delegates", Justification = "Test code - logging performance not critical")]
-[assembly: SuppressMessage("Design", "CA1816:Call GC.SuppressFinalize correctly", Justification = "Test classes do not need to call GC.SuppressFinalize.")]
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj
new file mode 100644
index 000000000..217996d41
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj
@@ -0,0 +1,35 @@
+
+
+ GoDaddy.Asherah.AppEncryption.PlugIns.Aws
+ AppEncryption.PlugIns.Aws
+ GoDaddy
+ GoDaddy
+ AWS extensions for Application level envelope encryption SDK for C#
+ net8.0;net9.0;net10.0;netstandard2.0
+
+
+ GoDaddy.Asherah.AppEncryption.PlugIns.Aws
+ true
+ true
+ Recommended
+ true
+ true
+
+ False
+ https://github.com/godaddy/asherah
+ https://github.com/godaddy/asherah/tree/main/csharp/AppEncryption
+ MIT
+ true
+ snupkg
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementClientFactory.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementClientFactory.cs
new file mode 100644
index 000000000..eb15855bb
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementClientFactory.cs
@@ -0,0 +1,17 @@
+using Amazon.KeyManagementService;
+
+namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Factory interface for creating AWS KMS clients for specific regions.
+ ///
+ public interface IKeyManagementClientFactory
+ {
+ ///
+ /// Creates a KMS client for the specified region.
+ ///
+ /// The AWS region name.
+ /// A KMS client configured for the specified region.
+ IAmazonKeyManagementService CreateForRegion(string region);
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementServiceBuilder.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementServiceBuilder.cs
new file mode 100644
index 000000000..5ae3da6c9
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementServiceBuilder.cs
@@ -0,0 +1,58 @@
+using Amazon.Runtime;
+using Microsoft.Extensions.Logging;
+
+namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Builder for KeyManagementServiceOptions.
+ ///
+ public interface IKeyManagementServiceBuilder
+ {
+ ///
+ /// Adds a logger factory to the builder. Required for logging within the KeyManagementService.
+ ///
+ ///
+ ///
+ IKeyManagementServiceBuilder WithLoggerFactory(ILoggerFactory loggerFactory);
+
+ ///
+ /// Credentials are needed to create the AWS KMS clients for each region.
+ /// This is not required if providing your own
+ /// which can handle credentials itself.
+ ///
+ ///
+ ///
+ IKeyManagementServiceBuilder WithCredentials(AWSCredentials credentials);
+
+ ///
+ /// Use to provied your own implementation of .
+ /// This allows your application to customize the creation of the AWS KMS clients for each region requested.
+ ///
+ ///
+ ///
+ IKeyManagementServiceBuilder WithKmsClientFactory(IKeyManagementClientFactory kmsClientFactory);
+
+ ///
+ /// Adds a region and key ARN pair to the builder. Note that this can be called multiple times to add multiple regions.
+ /// The order of the regions should be from most preferred to least preferred.
+ ///
+ /// A valid AWS region
+ /// The KMS key Arn from that region to use as your master key
+ ///
+ IKeyManagementServiceBuilder WithRegionKeyArn(string region, string keyArn);
+
+ ///
+ /// Used to provide all the regions and key Anrs at once using a strongly typed options object.
+ /// This can easily be deserialized from a configuration file and used instead of calling WithRegionKeyArn multiple times.
+ ///
+ ///
+ ///
+ IKeyManagementServiceBuilder WithOptions(KeyManagementServiceOptions options);
+
+ ///
+ /// Returns a new instance of with the configured options.
+ ///
+ ///
+ KeyManagementService Build();
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementClientFactory.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementClientFactory.cs
new file mode 100644
index 000000000..10c5fd96d
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementClientFactory.cs
@@ -0,0 +1,48 @@
+using System;
+using Amazon;
+using Amazon.KeyManagementService;
+using Amazon.Runtime;
+
+namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Simple implementation of that creates KMS clients
+ /// for any region using provided AWS credentials. Alternative implementations can be used
+ /// if your application requires more complex credential management or client configuration.
+ ///
+ public class KeyManagementClientFactory : IKeyManagementClientFactory
+ {
+ private readonly AWSCredentials _credentials;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The AWS credentials to use for authentication.
+ public KeyManagementClientFactory(AWSCredentials credentials)
+ {
+ _credentials = credentials;
+ }
+
+ ///
+ public IAmazonKeyManagementService CreateForRegion(string region)
+ {
+ if (string.IsNullOrWhiteSpace(region))
+ {
+ throw new ArgumentException("Region cannot be null or empty", nameof(region));
+ }
+
+ // GetBySystemName will always return a RegionEndpoint. Sometimes with an invalid-name
+ // but it could be working because AWS SDK matches to similar regions. So we don't
+ // do any extra validation on the regionEndpoint here because the application intention
+ // is not known
+ var regionEndpoint = RegionEndpoint.GetBySystemName(region);
+
+ var config = new AmazonKeyManagementServiceConfig
+ {
+ RegionEndpoint = regionEndpoint
+ };
+
+ return new AmazonKeyManagementServiceClient(_credentials, config);
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementService.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementService.cs
new file mode 100644
index 000000000..f2d348f28
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementService.cs
@@ -0,0 +1,328 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading.Tasks;
+using Amazon.KeyManagementService;
+using Amazon.KeyManagementService.Model;
+using GoDaddy.Asherah.AppEncryption.Exceptions;
+using GoDaddy.Asherah.AppEncryption.Kms;
+using GoDaddy.Asherah.Crypto.BufferUtils;
+using GoDaddy.Asherah.Crypto.Engine.BouncyCastle;
+using GoDaddy.Asherah.Crypto.Keys;
+using Microsoft.Extensions.Logging;
+
+namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// AWS-specific implementation of .
+ ///
+ public sealed class KeyManagementService : IKeyManagementService, IDisposable
+ {
+ private readonly IReadOnlyList _kmsArnClients;
+ private readonly ILogger _logger;
+ private readonly BouncyAes256GcmCrypto _crypto = new BouncyAes256GcmCrypto();
+
+ private static readonly Action LogFailedGenerateDataKey = LoggerMessage.Define(
+ LogLevel.Warning,
+ new EventId(1, nameof(KeyManagementService)),
+ "Failed to generate data key via region {Region} KMS, trying next region");
+
+ private static readonly Action LogEncryptError = LoggerMessage.Define(
+ LogLevel.Error,
+ new EventId(2, nameof(KeyManagementService)),
+ "Unexpected execution exception while encrypting KMS data key");
+
+ private static readonly Action LogDecryptWarning = LoggerMessage.Define(
+ LogLevel.Warning,
+ new EventId(3, nameof(KeyManagementService)),
+ "Failed to decrypt via region {Region} KMS, trying next region");
+
+ ///
+ /// Creates a new builder for KeyManagementService.
+ ///
+ ///
+ public static IKeyManagementServiceBuilder NewBuilder() => new KeyManagementServiceBuilder();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Key Management Service configuration options.
+ /// Factory for creating KMS clients for specific regions.
+ /// Factory for creating loggers.
+ public KeyManagementService(KeyManagementServiceOptions kmsOptions, IKeyManagementClientFactory clientFactory, ILoggerFactory loggerFactory)
+ {
+ _logger = loggerFactory.CreateLogger();
+
+ // Build out the KMS ARN clients
+ var kmsArnClients = new List();
+
+ foreach (var regionKeyArn in kmsOptions.RegionKeyArns)
+ {
+ var client = clientFactory.CreateForRegion(regionKeyArn.Region);
+ kmsArnClients.Add(new KmsArnClient(regionKeyArn.KeyArn, client, regionKeyArn.Region));
+ }
+
+ _kmsArnClients = kmsArnClients.AsReadOnly();
+ }
+
+ ///
+ public byte[] EncryptKey(CryptoKey key)
+ {
+ return EncryptKeyAsync(key).GetAwaiter().GetResult();
+ }
+
+ ///
+ public CryptoKey DecryptKey(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked)
+ {
+ return DecryptKeyAsync(keyCipherText, keyCreated, revoked).GetAwaiter().GetResult();
+ }
+
+ ///
+ public async Task DecryptKeyAsync(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked)
+ {
+ var kmsKeyEnvelope = JsonSerializer.Deserialize(keyCipherText);
+ var encryptedKey = Convert.FromBase64String(kmsKeyEnvelope.EncryptedKey);
+
+ foreach (var kmsArnClient in _kmsArnClients)
+ {
+ var matchingKmsKek = kmsKeyEnvelope.KmsKeks.FirstOrDefault(kek =>
+ kek.Region.Equals(kmsArnClient.Region, StringComparison.OrdinalIgnoreCase));
+
+ if (matchingKmsKek == null)
+ {
+ continue;
+ }
+
+ var kmsKeyEncryptionKey = Convert.FromBase64String(matchingKmsKek.EncryptedKek);
+
+ try
+ {
+ return await DecryptKmsEncryptedKey(kmsArnClient, encryptedKey, keyCreated, kmsKeyEncryptionKey, revoked);
+ }
+ catch (Exception ex)
+ {
+ LogDecryptWarning(_logger, kmsArnClient.Region, ex);
+ }
+ }
+
+ throw new KmsException("Could not successfully decrypt key using any regions");
+ }
+
+ ///
+ public async Task EncryptKeyAsync(CryptoKey key)
+ {
+ var (dataKey, dataKeyKeyId) = await GenerateDataKeyAsync();
+ var dataKeyPlainText = dataKey.Plaintext.GetBuffer();
+
+ try
+ {
+ var dataKeyCryptoKey = _crypto.GenerateKeyFromBytes(dataKeyPlainText);
+ var encryptedKey = _crypto.EncryptKey(key, dataKeyCryptoKey);
+
+ var kmsKeyEnvelope = new KmsKeyEnvelope
+ {
+ EncryptedKey = Convert.ToBase64String(encryptedKey)
+ };
+
+ foreach (var kmsArnClient in _kmsArnClients)
+ {
+ if (!kmsArnClient.Arn.Equals(dataKeyKeyId, StringComparison.Ordinal))
+ {
+ // If the ARN is different than the datakey's, call encrypt since it's another region
+ var kmsKek = await CreateKmsKek(kmsArnClient, dataKeyPlainText);
+ kmsKeyEnvelope.KmsKeks.Add(kmsKek);
+ }
+ else
+ {
+ // This is the datakey, so build kmsKey json for it
+ var kmsKek = new KmsKek
+ {
+ Region = kmsArnClient.Region,
+ Arn = kmsArnClient.Arn,
+ EncryptedKek = Convert.ToBase64String(dataKey.CiphertextBlob.GetBuffer())
+ };
+ kmsKeyEnvelope.KmsKeks.Add(kmsKek);
+ }
+ }
+
+ return JsonSerializer.SerializeToUtf8Bytes(kmsKeyEnvelope);
+ }
+ catch (Exception ex)
+ {
+ LogEncryptError(_logger, ex);
+ throw new KmsException("unexpected execution error during encrypt");
+ }
+ finally
+ {
+ ManagedBufferUtils.WipeByteArray(dataKeyPlainText);
+ }
+ }
+
+ ///
+ /// Generates a KMS data key for encryption.
+ ///
+ /// A tuple containing the response and the key ID used for the data key.
+ private async Task<(GenerateDataKeyResponse response, string dataKeyKeyId)> GenerateDataKeyAsync()
+ {
+ foreach (var kmsArnClient in _kmsArnClients)
+ {
+ try
+ {
+ var request = new GenerateDataKeyRequest
+ {
+ KeyId = kmsArnClient.Arn,
+ KeySpec = DataKeySpec.AES_256,
+ };
+
+ var response = await kmsArnClient.Client.GenerateDataKeyAsync(request);
+ return (response, kmsArnClient.Arn);
+ }
+ catch (Exception ex)
+ {
+ LogFailedGenerateDataKey(_logger, kmsArnClient.Region, ex);
+ }
+ }
+
+ throw new KmsException("Could not successfully generate data key using any regions");
+ }
+
+ ///
+ /// Decrypts a KMS encrypted key using the specified client and parameters.
+ ///
+ /// The KMS ARN client containing client, region, and ARN.
+ /// The encrypted key to decrypt.
+ /// When the key was created.
+ /// The encrypted KMS key.
+ /// Whether the key is revoked.
+ /// The decrypted crypto key.
+ private async Task DecryptKmsEncryptedKey(
+ KmsArnClient kmsArnClient,
+ byte[] encryptedKey,
+ DateTimeOffset keyCreated,
+ byte[] kmsKeyEncryptionKey,
+ bool revoked)
+ {
+ DecryptResponse response;
+ byte[] plaintextBackingBytes;
+
+ // Create a MemoryStream that we will dispose properly
+ using (var ciphertextBlobStream = new MemoryStream(kmsKeyEncryptionKey))
+ {
+ var request = new DecryptRequest
+ {
+ CiphertextBlob = ciphertextBlobStream,
+ };
+
+ response = await kmsArnClient.Client.DecryptAsync(request);
+ }
+
+ // Use proper disposal of the response plaintext stream and securely handle the sensitive bytes
+ using (var plaintextStream = response.Plaintext)
+ {
+ // Extract the plaintext bytes so we can wipe them in case of an exception
+ plaintextBackingBytes = plaintextStream.GetBuffer();
+
+ try
+ {
+ return _crypto.DecryptKey(encryptedKey, keyCreated, _crypto.GenerateKeyFromBytes(plaintextBackingBytes), revoked);
+ }
+ finally
+ {
+ ManagedBufferUtils.WipeByteArray(plaintextBackingBytes);
+ }
+ }
+ }
+
+ ///
+ /// Encrypts a data key for a specific region and builds the result.
+ ///
+ /// The KMS ARN client containing client, region, and ARN.
+ /// The plaintext data key to encrypt.
+ /// A KmsKek object containing the encrypted result.
+ private static async Task CreateKmsKek(
+ KmsArnClient kmsArnClient,
+ byte[] dataKeyPlainText)
+ {
+ using (var plaintextStream = new MemoryStream(dataKeyPlainText))
+ {
+ var encryptRequest = new EncryptRequest
+ {
+ KeyId = kmsArnClient.Arn,
+ Plaintext = plaintextStream
+ };
+
+ var encryptResponse = await kmsArnClient.Client.EncryptAsync(encryptRequest);
+
+ // Process the response - ciphertext doesn't need wiping
+ using (var ciphertextStream = encryptResponse.CiphertextBlob)
+ {
+ // Get the ciphertext bytes
+ var ciphertextBytes = new byte[ciphertextStream.Length];
+ ciphertextStream.Position = 0;
+ ciphertextStream.Read(ciphertextBytes, 0, ciphertextBytes.Length);
+
+ // Create and return the KmsKek object
+ return new KmsKek
+ {
+ Region = kmsArnClient.Region,
+ Arn = kmsArnClient.Arn,
+ EncryptedKek = Convert.ToBase64String(ciphertextBytes)
+ };
+ }
+ }
+ }
+
+ ///
+ /// Private class representing the KMS key envelope structure.
+ ///
+ private sealed class KmsKeyEnvelope
+ {
+ ///
+ /// Gets or sets the encrypted key.
+ ///
+ [JsonPropertyName("encryptedKey")]
+ public string EncryptedKey { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the list of KMS key encryption keys.
+ ///
+ [JsonPropertyName("kmsKeks")]
+ public List KmsKeks { get; set; } = new List();
+ }
+
+ ///
+ /// Private class representing a KMS key encryption key entry.
+ ///
+ private sealed class KmsKek
+ {
+ ///
+ /// Gets or sets the AWS region.
+ ///
+ [JsonPropertyName("region")]
+ public string Region { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the KMS key ARN.
+ ///
+ [JsonPropertyName("arn")]
+ public string Arn { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the encrypted key encryption key.
+ ///
+ [JsonPropertyName("encryptedKek")]
+ public string EncryptedKek { get; set; } = string.Empty;
+ }
+
+ ///
+ /// Disposes the resources used by this instance.
+ ///
+ public void Dispose()
+ {
+ _crypto?.Dispose();
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceBuilder.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceBuilder.cs
new file mode 100644
index 000000000..80f3a1acd
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceBuilder.cs
@@ -0,0 +1,98 @@
+using System.Collections.Generic;
+using Amazon.Runtime;
+using Microsoft.Extensions.Logging;
+
+namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms
+{
+ internal sealed class KeyManagementServiceBuilder : IKeyManagementServiceBuilder
+ {
+ private ILoggerFactory _loggerFactory;
+ private AWSCredentials _credentials;
+ private IKeyManagementClientFactory _kmsClientFactory;
+ private KeyManagementServiceOptions _kmsOptions;
+ private readonly List _regionKeyArns = new List(4);
+
+ public IKeyManagementServiceBuilder WithLoggerFactory(ILoggerFactory loggerFactory)
+ {
+ _loggerFactory = loggerFactory;
+ return this;
+ }
+
+ public IKeyManagementServiceBuilder WithCredentials(AWSCredentials credentials)
+ {
+ _credentials = credentials;
+ return this;
+ }
+
+ public IKeyManagementServiceBuilder WithKmsClientFactory(IKeyManagementClientFactory kmsClientFactory)
+ {
+ _kmsClientFactory = kmsClientFactory;
+ return this;
+ }
+
+ public IKeyManagementServiceBuilder WithRegionKeyArn(string region, string keyArn)
+ {
+ if (string.IsNullOrEmpty(region))
+ {
+ throw new System.ArgumentException("Region cannot be null or empty", nameof(region));
+ }
+
+ if (string.IsNullOrEmpty(keyArn))
+ {
+ throw new System.ArgumentException("Key ARN cannot be null or empty", nameof(keyArn));
+ }
+
+ _regionKeyArns.Add(new RegionKeyArn { Region = region, KeyArn = keyArn });
+ return this;
+ }
+
+ public IKeyManagementServiceBuilder WithOptions(KeyManagementServiceOptions options)
+ {
+ _kmsOptions = options;
+ return this;
+ }
+
+ public KeyManagementService Build()
+ {
+ if (_loggerFactory == null)
+ {
+ throw new System.InvalidOperationException("LoggerFactory must be provided");
+ }
+
+ var resolvedOptions = ResolveOptions();
+ var resolvedClientFactory = ResolveClientFactory();
+
+ return new KeyManagementService(resolvedOptions, resolvedClientFactory, _loggerFactory);
+ }
+
+ private IKeyManagementClientFactory ResolveClientFactory()
+ {
+ if (_kmsClientFactory != null)
+ {
+ return _kmsClientFactory;
+ }
+
+ return _credentials == null
+ ? throw new System.InvalidOperationException("Either credentials or a KMS client factory must be provided")
+ : new KeyManagementClientFactory(_credentials);
+ }
+
+ private KeyManagementServiceOptions ResolveOptions()
+ {
+ if (_kmsOptions != null)
+ {
+ return _kmsOptions;
+ }
+
+ if (_regionKeyArns.Count == 0)
+ {
+ throw new System.InvalidOperationException("At least one region and key ARN pair must be provided if not using WithOptions");
+ }
+
+ return new KeyManagementServiceOptions
+ {
+ RegionKeyArns = _regionKeyArns
+ };
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptions.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptions.cs
new file mode 100644
index 000000000..cb0cf4ddf
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptions.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Options for configuring the AWS Key Management Service.
+ ///
+ public class KeyManagementServiceOptions
+ {
+ ///
+ /// Gets or sets the list of region and key ARN pairs for multi-region KMS support.
+ ///
+ [JsonPropertyName("regionKeyArns")]
+ public List RegionKeyArns { get; set; } = new List();
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptionsExtensions.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptionsExtensions.cs
new file mode 100644
index 000000000..679186e28
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptionsExtensions.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Extension methods for .
+ ///
+ public static class KeyManagementServiceOptionsExtensions
+ {
+ ///
+ /// Returns a new instance of with the RegionKeyArns
+ /// sorted by the priority regions order. Priority regions will appear first in the order specified,
+ /// followed by any regions not in the priority list (maintaining their original order).
+ ///
+ /// The key management service options to optimize.
+ /// Region names in the desired priority order.
+ /// A new instance with sorted RegionKeyArns.
+ public static KeyManagementServiceOptions OptimizeByRegions(this KeyManagementServiceOptions options, params string[] priorityRegions)
+ {
+ // Create a dictionary for quick lookup of priority region indices
+ var priorityRegionIndices = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ for (var i = 0; i < priorityRegions.Length; i++)
+ {
+ if (!string.IsNullOrEmpty(priorityRegions[i]))
+ {
+ priorityRegionIndices[priorityRegions[i]] = i;
+ }
+ }
+
+ // Sort: priority regions first (by their index in priorityRegions), then non-priority (maintain original order)
+ var sortedRegionKeyArns = options.RegionKeyArns
+ .Select((rka, index) => new { RegionKeyArn = rka, OriginalIndex = index })
+ .OrderBy(item =>
+ {
+ if (priorityRegionIndices.TryGetValue(item.RegionKeyArn.Region, out var priorityIndex))
+ {
+ return priorityIndex;
+ }
+ // Non-priority regions go after all priority ones
+ return int.MaxValue;
+ })
+ .ThenBy(item => item.OriginalIndex) // Maintain original order for non-priority regions
+ .Select(item => item.RegionKeyArn)
+ .ToList();
+
+ return new KeyManagementServiceOptions
+ {
+ RegionKeyArns = sortedRegionKeyArns
+ };
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KmsArnClient.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KmsArnClient.cs
new file mode 100644
index 000000000..1ac49c98d
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KmsArnClient.cs
@@ -0,0 +1,38 @@
+using Amazon.KeyManagementService;
+
+namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Internal class that holds a KMS ARN and its corresponding client.
+ ///
+ internal sealed class KmsArnClient
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The KMS key ARN.
+ /// The Amazon KMS client.
+ /// The AWS region.
+ public KmsArnClient(string arn, IAmazonKeyManagementService client, string region)
+ {
+ Arn = arn;
+ Client = client;
+ Region = region;
+ }
+
+ ///
+ /// Gets the KMS key ARN.
+ ///
+ public string Arn { get; }
+
+ ///
+ /// Gets the Amazon KMS client.
+ ///
+ public IAmazonKeyManagementService Client { get; }
+
+ ///
+ /// Gets the AWS region.
+ ///
+ public string Region { get; }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/RegionKeyArn.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/RegionKeyArn.cs
new file mode 100644
index 000000000..1c6a77368
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/RegionKeyArn.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+
+namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Represents a region and its corresponding KMS key ARN.
+ ///
+ public class RegionKeyArn
+ {
+ ///
+ /// Gets or sets the AWS region name.
+ ///
+ [JsonPropertyName("region")]
+ public string Region { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the KMS key ARN for the region.
+ ///
+ [JsonPropertyName("keyArn")]
+ public string KeyArn { get; set; } = string.Empty;
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj
index da5d3a337..e452a8565 100644
--- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj
@@ -5,11 +5,12 @@
GoDaddy.Asherah.AppEncryption.Tests
true
true
- Recommended
+ Minimum
true
+ $(NoWarn);CS0618;CA1816
-
+
all
@@ -30,5 +31,6 @@
+
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs
index 31f41505a..d8764f6ac 100644
--- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs
@@ -10,9 +10,14 @@
using Amazon.KeyManagementService.Model;
using Amazon.Runtime;
using GoDaddy.Asherah.AppEncryption.Exceptions;
+using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms;
using GoDaddy.Asherah.AppEncryption.Kms;
+using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms;
+using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers;
+using GoDaddy.Asherah.Crypto.Engine.BouncyCastle;
using GoDaddy.Asherah.Crypto.Envelope;
using GoDaddy.Asherah.Crypto.Exceptions;
+using GoDaddy.Asherah.Crypto.ExtensionMethods;
using GoDaddy.Asherah.Crypto.Keys;
using LanguageExt;
using Microsoft.Extensions.Logging;
@@ -677,5 +682,43 @@ public void TestEncryptKeyShouldThrowExceptionAndWipeBytes()
// Ensure the buffer containing the data key is wiped
Assert.Equal(new byte[] { 0, 0 }, dataKeyPlainText);
}
+
+ ///
+ /// This test is verifying that the stub implementation of the KMS client is working correctly
+ /// for both implementations of the IKeyManagementService classes
+ ///
+ [Fact]
+ public void TestEncryptAndDecryptKeyWithStub()
+ {
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns =
+ [
+ new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 },
+ new RegionKeyArn { Region = UsWest1, KeyArn = ArnUsWest1 }
+ ]
+ };
+ var awsKeyManagementServiceImpl = new AwsKeyManagementServiceImpl(
+ regionToArnDictionary,
+ preferredRegion,
+ new BouncyAes256GcmCrypto(),
+ new KeyManagementClientFactoryStub(options),
+ new AnonymousAWSCredentials(),
+ new LoggerFactoryStub().CreateLogger("AwsKeyManagementServiceImplTest")
+ );
+
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var originalKey = crypto.GenerateKey(keyCreationTime);
+
+ // Act
+ var encryptedResult = awsKeyManagementServiceImpl.EncryptKey(originalKey);
+ var decryptedKey = awsKeyManagementServiceImpl.DecryptKey(encryptedResult, keyCreationTime, revoked: false);
+
+ // Assert
+ Assert.NotNull(decryptedKey);
+ Assert.Equal(originalKey.WithKey(keyBytes => keyBytes), decryptedKey.WithKey(keyBytes => keyBytes));
+ Assert.Equal(originalKey.GetCreated(), decryptedKey.GetCreated());
+ }
}
}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/AwsKeyManagementStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/AwsKeyManagementStub.cs
new file mode 100644
index 000000000..62e880973
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/AwsKeyManagementStub.cs
@@ -0,0 +1,495 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Amazon.KeyManagementService;
+using Amazon.KeyManagementService.Model;
+using Amazon.Runtime;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Stub implementation of IAmazonKeyManagementService for testing purposes.
+ ///
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The key ARN for this stub.
+ [ExcludeFromCodeCoverage]
+ public class AwsKeyManagementStub(string keyArn) : IAmazonKeyManagementService
+ {
+ public IClientConfig Config => throw new NotImplementedException();
+ public IKeyManagementServicePaginatorFactory Paginators => throw new NotImplementedException();
+
+ public Task CancelKeyDeletionAsync(CancelKeyDeletionRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CancelKeyDeletionAsync(string keyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ConnectCustomKeyStoreAsync(ConnectCustomKeyStoreRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CreateAliasAsync(CreateAliasRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CreateAliasAsync(string aliasName, string targetKeyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CreateCustomKeyStoreAsync(CreateCustomKeyStoreRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CreateGrantAsync(CreateGrantRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task CreateKeyAsync(CreateKeyRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DecryptAsync(DecryptRequest request, CancellationToken cancellationToken = default)
+ {
+ // Read the ciphertext from the request using GetBuffer() like the real implementations
+ byte[] ciphertext;
+ using (var stream = request.CiphertextBlob)
+ {
+ ciphertext = new byte[stream.Length];
+ stream.ReadExactly(ciphertext, 0, ciphertext.Length);
+ }
+
+ var keyBytes = System.Text.Encoding.UTF8.GetBytes(keyArn);
+
+ // Verify that the first bytes match the _keyBytes
+ if (ciphertext.Length < keyBytes.Length)
+ {
+ throw new AmazonServiceException($"Ciphertext too short. Expected at least {keyBytes.Length} bytes, got {ciphertext.Length}");
+ }
+
+ for (var i = 0; i < keyBytes.Length; i++)
+ {
+ if (ciphertext[i] != keyBytes[i])
+ {
+ throw new AmazonServiceException($"Ciphertext key bytes don't match expected _keyBytes at position {i}");
+ }
+ }
+
+ // Remove the _keyBytes from the beginning of the ciphertext
+ var plaintext = new byte[ciphertext.Length - keyBytes.Length];
+ Array.Copy(ciphertext, keyBytes.Length, plaintext, 0, plaintext.Length);
+
+ // Simply copy the modified bytes to plaintext
+ var response = new DecryptResponse
+ {
+ Plaintext = new MemoryStream(plaintext, 0, plaintext.Length, false, true),
+ };
+
+ return Task.FromResult(response);
+ }
+
+ public Task DeleteAliasAsync(DeleteAliasRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DeleteAliasAsync(string aliasName, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DeleteCustomKeyStoreAsync(DeleteCustomKeyStoreRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DeleteImportedKeyMaterialAsync(DeleteImportedKeyMaterialRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DeriveSharedSecretAsync(DeriveSharedSecretRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DescribeCustomKeyStoresAsync(DescribeCustomKeyStoresRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DescribeKeyAsync(DescribeKeyRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DescribeKeyAsync(string keyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DisableKeyAsync(DisableKeyRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DisableKeyAsync(string keyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DisableKeyRotationAsync(DisableKeyRotationRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DisableKeyRotationAsync(string keyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task DisconnectCustomKeyStoreAsync(DisconnectCustomKeyStoreRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task EnableKeyAsync(EnableKeyRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task EnableKeyAsync(string keyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task EnableKeyRotationAsync(EnableKeyRotationRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task EnableKeyRotationAsync(string keyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task EncryptAsync(EncryptRequest request, CancellationToken cancellationToken = default)
+ {
+ // Validate the KeyId matches our stored KeyArn
+ if (request.KeyId != keyArn)
+ {
+ throw new ArgumentException($"KeyId '{request.KeyId}' does not match expected KeyArn '{keyArn}'");
+ }
+
+ // Read the plaintext from the request
+ byte[] plaintext;
+ using (var stream = request.Plaintext)
+ {
+ plaintext = new byte[stream.Length];
+ stream.ReadExactly(plaintext, 0, plaintext.Length);
+ }
+
+ var keyBytes = System.Text.Encoding.UTF8.GetBytes(keyArn);
+
+ // Prepend the _keyBytes to the beginning of the plaintext
+ var ciphertext = new byte[keyBytes.Length + plaintext.Length];
+ Array.Copy(keyBytes, 0, ciphertext, 0, keyBytes.Length);
+ Array.Copy(plaintext, 0, ciphertext, keyBytes.Length, plaintext.Length);
+
+ // Simply copy the modified bytes to ciphertext blob
+ var response = new EncryptResponse
+ {
+ CiphertextBlob = new MemoryStream(ciphertext, 0, ciphertext.Length, true, true),
+ KeyId = keyArn
+ };
+ return Task.FromResult(response);
+ }
+
+ public Task GenerateDataKeyAsync(GenerateDataKeyRequest request, CancellationToken cancellationToken = default)
+ {
+ // Validate the KeyId matches our stored KeyArn
+ if (request.KeyId != keyArn)
+ {
+ throw new ArgumentException($"KeyId '{request.KeyId}' does not match expected KeyArn '{keyArn}'");
+ }
+
+ // Simulated error from KMS
+ if (keyArn == "ERROR")
+ {
+ throw new KeyUnavailableException("Simulated KMS error for testing purposes");
+ }
+
+ // Generate fake data based on the _keyArn to make it unique per ARN
+ var fakePlaintext = GenerateFakeDataFromArn(keyArn, "plaintext");
+ var fakeCiphertext = GenerateFakeDataFromArn(keyArn, "ciphertext");
+
+ var response = new GenerateDataKeyResponse
+ {
+ Plaintext = new MemoryStream(fakePlaintext, 0, fakePlaintext.Length, true, true),
+ CiphertextBlob = new MemoryStream(fakeCiphertext, 0, fakeCiphertext.Length, true, true),
+ KeyId = keyArn
+ };
+ return Task.FromResult(response);
+ }
+
+ private static byte[] GenerateFakeDataFromArn(string keyArn, string suffix)
+ {
+ // Create deterministic fake data based on the ARN and suffix
+ var input = $"{keyArn}-{suffix}";
+ var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input));
+
+ // Take first 32 bytes for AES-256 key size
+ var result = new byte[32];
+ Array.Copy(hash, result, Math.Min(hash.Length, result.Length));
+ return result;
+ }
+
+ public Task GenerateDataKeyPairAsync(GenerateDataKeyPairRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GenerateDataKeyPairWithoutPlaintextAsync(GenerateDataKeyPairWithoutPlaintextRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GenerateDataKeyWithoutPlaintextAsync(GenerateDataKeyWithoutPlaintextRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GenerateMacAsync(GenerateMacRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GenerateRandomAsync(GenerateRandomRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GenerateRandomAsync(int? numberOfBytes, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GetKeyPolicyAsync(GetKeyPolicyRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GetKeyPolicyAsync(string keyId, string policyName, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GetKeyRotationStatusAsync(GetKeyRotationStatusRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GetKeyRotationStatusAsync(string keyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GetParametersForImportAsync(GetParametersForImportRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task GetPublicKeyAsync(GetPublicKeyRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ImportKeyMaterialAsync(ImportKeyMaterialRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ListAliasesAsync(ListAliasesRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ListGrantsAsync(ListGrantsRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ListKeyPoliciesAsync(ListKeyPoliciesRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ListKeyRotationsAsync(ListKeyRotationsRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ListKeysAsync(ListKeysRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ListResourceTagsAsync(ListResourceTagsRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ListRetirableGrantsAsync(ListRetirableGrantsRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ListRetirableGrantsAsync(string retiringPrincipal, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ListRetirableGrantsAsync(CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task PutKeyPolicyAsync(PutKeyPolicyRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task PutKeyPolicyAsync(string keyId, string policy, string policyName, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ReEncryptAsync(ReEncryptRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ReplicateKeyAsync(ReplicateKeyRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task RetireGrantAsync(RetireGrantRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task RetireGrantAsync(string grantToken, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task RevokeGrantAsync(RevokeGrantRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task RevokeGrantAsync(string grantId, string keyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task RotateKeyOnDemandAsync(RotateKeyOnDemandRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ScheduleKeyDeletionAsync(ScheduleKeyDeletionRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ScheduleKeyDeletionAsync(string keyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task ScheduleKeyDeletionAsync(string keyId, int? pendingWindowInDays, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task SignAsync(SignRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task TagResourceAsync(TagResourceRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task UntagResourceAsync(UntagResourceRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task UpdateAliasAsync(UpdateAliasRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task UpdateAliasAsync(string aliasName, string targetKeyId, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task UpdateCustomKeyStoreAsync(UpdateCustomKeyStoreRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task UpdateKeyDescriptionAsync(UpdateKeyDescriptionRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task UpdateKeyDescriptionAsync(string keyId, string description, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task UpdatePrimaryRegionAsync(UpdatePrimaryRegionRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task VerifyAsync(VerifyRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Task VerifyMacAsync(VerifyMacRequest request, CancellationToken cancellationToken = default)
+ {
+ throw new NotImplementedException();
+ }
+
+ public Amazon.Runtime.Endpoints.Endpoint DetermineServiceOperationEndpoint(AmazonWebServiceRequest request)
+ {
+ throw new NotImplementedException();
+ }
+
+ public void Dispose()
+ {
+ throw new NotImplementedException();
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryStub.cs
new file mode 100644
index 000000000..5c8dc0177
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryStub.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using Amazon.KeyManagementService;
+using Amazon.Runtime;
+using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms;
+using GoDaddy.Asherah.AppEncryption.Kms;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Stub implementation of IKeyManagementClientFactory for testing purposes.
+ ///
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The key management service options.
+ [ExcludeFromCodeCoverage]
+ public class KeyManagementClientFactoryStub(KeyManagementServiceOptions options) : AwsKmsClientFactory, IKeyManagementClientFactory
+ {
+ private readonly Dictionary _clients = [];
+
+ ///
+ public IAmazonKeyManagementService CreateForRegion(string region)
+ {
+ if (_clients.TryGetValue(region, out var existingClient))
+ {
+ return existingClient;
+ }
+
+ var regionKeyArn = options.RegionKeyArns.FirstOrDefault(rka => rka.Region.Equals(region, StringComparison.OrdinalIgnoreCase))
+ ?? throw new InvalidOperationException($"No key ARN found for region: {region}");
+
+ var client = new AwsKeyManagementStub(regionKeyArn.KeyArn);
+ _clients[region] = client;
+ return client;
+ }
+
+ internal override IAmazonKeyManagementService CreateAwsKmsClient(string region, AWSCredentials credentials)
+ {
+ return CreateForRegion(region);
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryTests.cs
new file mode 100644
index 000000000..373aed50a
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryTests.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Amazon.Runtime;
+using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms;
+using GoDaddy.Asherah.SecureMemory;
+using Xunit;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms;
+
+[ExcludeFromCodeCoverage]
+public class KeyManagementClientFactoryTests
+{
+ private readonly KeyManagementClientFactory _clientFactory;
+
+ public KeyManagementClientFactoryTests()
+ {
+ var credentials = new AnonymousAWSCredentials();
+ _clientFactory = new KeyManagementClientFactory(credentials);
+ }
+
+ // Verify that passing invalid region throws ArgumentException
+ [InlineData(null)]
+ [InlineData("")]
+ [Theory]
+ public void TestCreateForRegion_InvalidRegion_ThrowsArgumentException(string region)
+ {
+ Assert.Throws((Action)CreateForRegion);
+ return;
+
+ void CreateForRegion()
+ {
+ _ = _clientFactory.CreateForRegion(region);
+ }
+ }
+
+ // Verify that passing a non-empty region returns a non-null client
+ [InlineData("us-east-1")]
+ [InlineData("us-west-2")]
+ [InlineData("eu-west-2")]
+ [InlineData("ap-southeast-1")]
+ [InlineData("invalid-region")]
+ [InlineData("us-west-99")]
+ [Theory]
+ public void TestCreateForRegion_ValidRegion_Succeeds(string region)
+ {
+ var kms = _clientFactory.CreateForRegion(region);
+ Assert.NotNull(kms);
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceBuilderTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceBuilderTests.cs
new file mode 100644
index 000000000..526b61623
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceBuilderTests.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Amazon.KeyManagementService;
+using Amazon.Runtime;
+using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms;
+using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers;
+using Moq;
+using Xunit;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms;
+
+[ExcludeFromCodeCoverage]
+public class KeyManagementServiceBuilderTests : IDisposable
+{
+ private readonly LoggerFactoryStub _loggerFactoryStub = new();
+
+ [Theory]
+ [InlineData(null, "arn:aws:kms:us-east-1:123456789012:key/abc", "region")]
+ [InlineData("", "arn:aws:kms:us-east-1:123456789012:key/abc", "region")]
+ [InlineData("us-east-1", null, "keyArn")]
+ [InlineData("us-east-1", "", "keyArn")]
+ public void WithRegionKeyArn_InvalidArguments_ThrowsArgumentException(string region, string keyArn, string expectedParamName)
+ {
+ var builder = KeyManagementService.NewBuilder();
+
+ var ex = Assert.Throws(() => builder.WithRegionKeyArn(region, keyArn));
+ Assert.Equal(expectedParamName, ex.ParamName);
+ }
+
+ [Fact]
+ public void Build_WithoutLoggerFactory_ThrowsInvalidOperationException()
+ {
+ var builder = KeyManagementService.NewBuilder();
+
+ var ex = Assert.Throws(() => builder.Build());
+ Assert.Equal("LoggerFactory must be provided", ex.Message);
+ }
+
+ [Fact]
+ public void Build_WithoutOptionsOrRegionKeyArns_ThrowsInvalidOperationException()
+ {
+ var builder = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(_loggerFactoryStub);
+
+ var ex = Assert.Throws(() => builder.Build());
+ Assert.Equal("At least one region and key ARN pair must be provided if not using WithOptions", ex.Message);
+ }
+
+ [Fact]
+ public void Build_WithoutCredentialsOrClientFactory_ThrowsInvalidOperationException()
+ {
+ var builder = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(_loggerFactoryStub)
+ .WithRegionKeyArn("us-east-1", "arn:aws:kms:us-east-1:123456789012:key/abc");
+
+ var ex = Assert.Throws(() => builder.Build());
+ Assert.Equal("Either credentials or a KMS client factory must be provided", ex.Message);
+ }
+
+ [Fact]
+ public void Build_WithAnonymousCredentials_Succeeds()
+ {
+ var builder = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(_loggerFactoryStub)
+ .WithRegionKeyArn("us-east-1", "arn:aws:kms:us-east-1:123456789012:key/abc")
+ .WithCredentials(new AnonymousAWSCredentials());
+
+ var kms = builder.Build();
+ Assert.NotNull(kms);
+ }
+
+ [Fact]
+ public void Build_WithKmsClientFactory_Succeeds()
+ {
+ var clientFactory = new KeyManagementClientFactory(new AnonymousAWSCredentials());
+
+ var builder = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(_loggerFactoryStub)
+ .WithRegionKeyArn("us-east-1", "arn:aws:kms:us-east-1:123456789012:key/abc")
+ .WithKmsClientFactory(clientFactory);
+
+ var kms = builder.Build();
+ Assert.NotNull(kms);
+ }
+
+ [Fact]
+ public void Build_WithOptions_Succeeds()
+ {
+ var kmsOptions = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = [new RegionKeyArn { Region = "us-east-1", KeyArn = "arn:aws:kms:us-east-1:123456789012:key/abc" }]
+ };
+
+ var builder = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(_loggerFactoryStub)
+ .WithOptions(kmsOptions)
+ .WithCredentials(new AnonymousAWSCredentials());
+
+ var kms = builder.Build();
+ Assert.NotNull(kms);
+ }
+
+ public void Dispose()
+ {
+ _loggerFactoryStub.Dispose();
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceOptionsTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceOptionsTests.cs
new file mode 100644
index 000000000..7634c293d
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceOptionsTests.cs
@@ -0,0 +1,225 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms;
+using Xunit;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms
+{
+ [ExcludeFromCodeCoverage]
+ public class KeyManagementServiceOptionsTests
+ {
+ [Fact]
+ public void OptimizeByRegions_WithPriorityRegions_SortsCorrectly()
+ {
+ // Arrange
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = new List
+ {
+ new RegionKeyArn { Region = "us-west-2", KeyArn = "arn-us-west-2" },
+ new RegionKeyArn { Region = "us-east-1", KeyArn = "arn-us-east-1" },
+ new RegionKeyArn { Region = "eu-west-1", KeyArn = "arn-eu-west-1" }
+ }
+ };
+
+ // Act
+ var result = options.OptimizeByRegions("us-east-1", "us-west-2");
+
+ // Assert
+ Assert.Equal(3, result.RegionKeyArns.Count);
+ Assert.Equal("us-east-1", result.RegionKeyArns[0].Region);
+ Assert.Equal("us-west-2", result.RegionKeyArns[1].Region);
+ Assert.Equal("eu-west-1", result.RegionKeyArns[2].Region);
+ }
+
+ [Fact]
+ public void OptimizeByRegions_WithSinglePriorityRegion_SortsCorrectly()
+ {
+ // Arrange
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = new List
+ {
+ new RegionKeyArn { Region = "us-west-2", KeyArn = "arn-us-west-2" },
+ new RegionKeyArn { Region = "us-east-1", KeyArn = "arn-us-east-1" },
+ new RegionKeyArn { Region = "eu-west-1", KeyArn = "arn-eu-west-1" }
+ }
+ };
+
+ // Act
+ var result = options.OptimizeByRegions("us-east-1");
+
+ // Assert
+ Assert.Equal(3, result.RegionKeyArns.Count);
+ Assert.Equal("us-east-1", result.RegionKeyArns[0].Region);
+ Assert.Equal("us-west-2", result.RegionKeyArns[1].Region);
+ Assert.Equal("eu-west-1", result.RegionKeyArns[2].Region);
+ }
+
+ [Fact]
+ public void OptimizeByRegions_WithPartialPriorityRegions_PutsPriorityFirst()
+ {
+ // Arrange
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = new List
+ {
+ new RegionKeyArn { Region = "us-west-2", KeyArn = "arn-us-west-2" },
+ new RegionKeyArn { Region = "us-east-1", KeyArn = "arn-us-east-1" },
+ new RegionKeyArn { Region = "eu-west-1", KeyArn = "arn-eu-west-1" },
+ new RegionKeyArn { Region = "ap-southeast-1", KeyArn = "arn-ap-southeast-1" }
+ }
+ };
+
+ // Act
+ var result = options.OptimizeByRegions("eu-west-1", "ap-southeast-1");
+
+ // Assert
+ Assert.Equal(4, result.RegionKeyArns.Count);
+ Assert.Equal("eu-west-1", result.RegionKeyArns[0].Region);
+ Assert.Equal("ap-southeast-1", result.RegionKeyArns[1].Region);
+ // Non-priority regions maintain original order
+ Assert.Equal("us-west-2", result.RegionKeyArns[2].Region);
+ Assert.Equal("us-east-1", result.RegionKeyArns[3].Region);
+ }
+
+ [Fact]
+ public void OptimizeByRegions_WithNoMatchingPriorityRegions_MaintainsOriginalOrder()
+ {
+ // Arrange
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = new List
+ {
+ new RegionKeyArn { Region = "us-west-2", KeyArn = "arn-us-west-2" },
+ new RegionKeyArn { Region = "us-east-1", KeyArn = "arn-us-east-1" }
+ }
+ };
+
+ // Act
+ var result = options.OptimizeByRegions("eu-west-1", "ap-southeast-1");
+
+ // Assert
+ Assert.Equal(2, result.RegionKeyArns.Count);
+ Assert.Equal("us-west-2", result.RegionKeyArns[0].Region);
+ Assert.Equal("us-east-1", result.RegionKeyArns[1].Region);
+ }
+
+ [Fact]
+ public void OptimizeByRegions_WithEmptyPriorityRegions_MaintainsOriginalOrder()
+ {
+ // Arrange
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = new List
+ {
+ new RegionKeyArn { Region = "us-west-2", KeyArn = "arn-us-west-2" },
+ new RegionKeyArn { Region = "us-east-1", KeyArn = "arn-us-east-1" }
+ }
+ };
+
+ // Act
+ var result = options.OptimizeByRegions();
+
+ // Assert
+ Assert.Equal(2, result.RegionKeyArns.Count);
+ Assert.Equal("us-west-2", result.RegionKeyArns[0].Region);
+ Assert.Equal("us-east-1", result.RegionKeyArns[1].Region);
+ }
+
+ [Fact]
+ public void OptimizeByRegions_IsCaseInsensitive()
+ {
+ // Arrange
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = new List
+ {
+ new RegionKeyArn { Region = "us-west-2", KeyArn = "arn-us-west-2" },
+ new RegionKeyArn { Region = "US-EAST-1", KeyArn = "arn-us-east-1" },
+ new RegionKeyArn { Region = "Eu-West-1", KeyArn = "arn-eu-west-1" }
+ }
+ };
+
+ // Act
+ var result = options.OptimizeByRegions("us-east-1", "eu-west-1");
+
+ // Assert
+ Assert.Equal(3, result.RegionKeyArns.Count);
+ Assert.Equal("US-EAST-1", result.RegionKeyArns[0].Region);
+ Assert.Equal("Eu-West-1", result.RegionKeyArns[1].Region);
+ Assert.Equal("us-west-2", result.RegionKeyArns[2].Region);
+ }
+
+ [Fact]
+ public void OptimizeByRegions_ReturnsNewInstance_DoesNotModifyOriginal()
+ {
+ // Arrange
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = new List
+ {
+ new RegionKeyArn { Region = "us-west-2", KeyArn = "arn-us-west-2" },
+ new RegionKeyArn { Region = "us-east-1", KeyArn = "arn-us-east-1" }
+ }
+ };
+ var originalOrder = options.RegionKeyArns.Select(rka => rka.Region).ToList();
+
+ // Act
+ var result = options.OptimizeByRegions("us-east-1");
+
+ // Assert
+ Assert.NotSame(options, result);
+ Assert.Equal(originalOrder, options.RegionKeyArns.Select(rka => rka.Region).ToList());
+ Assert.Equal("us-east-1", result.RegionKeyArns[0].Region);
+ }
+
+ [Fact]
+ public void OptimizeByRegions_WithDuplicatePriorityRegions_HandlesCorrectly()
+ {
+ // Arrange
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = new List
+ {
+ new RegionKeyArn { Region = "us-west-2", KeyArn = "arn-us-west-2" },
+ new RegionKeyArn { Region = "us-east-1", KeyArn = "arn-us-east-1" },
+ new RegionKeyArn { Region = "eu-west-1", KeyArn = "arn-eu-west-1" }
+ }
+ };
+
+ // Act
+ var result = options.OptimizeByRegions("us-east-1", "us-east-1", "us-west-2");
+
+ // Assert
+ Assert.Equal(3, result.RegionKeyArns.Count);
+ // First occurrence in priority list determines position
+ Assert.Equal("us-east-1", result.RegionKeyArns[0].Region);
+ Assert.Equal("us-west-2", result.RegionKeyArns[1].Region);
+ Assert.Equal("eu-west-1", result.RegionKeyArns[2].Region);
+ }
+
+ [Fact]
+ public void OptimizeByRegions_WithNullOrEmptyPriorityRegions_IgnoresThem()
+ {
+ // Arrange
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = new List
+ {
+ new RegionKeyArn { Region = "us-west-2", KeyArn = "arn-us-west-2" },
+ new RegionKeyArn { Region = "us-east-1", KeyArn = "arn-us-east-1" }
+ }
+ };
+
+ // Act
+ var result = options.OptimizeByRegions("us-east-1", null, "", "us-west-2");
+
+ // Assert
+ Assert.Equal(2, result.RegionKeyArns.Count);
+ Assert.Equal("us-east-1", result.RegionKeyArns[0].Region);
+ Assert.Equal("us-west-2", result.RegionKeyArns[1].Region);
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTestBuilder.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTestBuilder.cs
new file mode 100644
index 000000000..5bfb916c1
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTestBuilder.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms;
+using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers;
+using Microsoft.Extensions.Logging;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms
+{
+ ///
+ /// Builder for creating KeyManagementService instances in tests with clear, explicit configuration.
+ ///
+ [ExcludeFromCodeCoverage]
+ public class KeyManagementServiceTestBuilder
+ {
+ private readonly List _regionKeyArns = new();
+ private readonly HashSet _brokenRegions = new();
+ private ILoggerFactory _loggerFactory = null;
+
+ private KeyManagementServiceTestBuilder()
+ {
+ }
+
+ ///
+ /// Creates a new builder instance.
+ ///
+ public static KeyManagementServiceTestBuilder Create()
+ {
+ return new KeyManagementServiceTestBuilder();
+ }
+
+ ///
+ /// Adds a region with its ARN. The ARN will be automatically generated if not provided.
+ ///
+ public KeyManagementServiceTestBuilder WithRegion(string region, string arn = null)
+ {
+ _regionKeyArns.Add(new RegionKeyArn
+ {
+ Region = region,
+ KeyArn = arn ?? $"arn-{region}"
+ });
+ return this;
+ }
+
+ ///
+ /// Adds multiple regions with their ARNs. ARNs will be automatically generated if not provided.
+ ///
+ public KeyManagementServiceTestBuilder WithRegions(params (string Region, string Arn)[] regions)
+ {
+ foreach (var (region, arn) in regions)
+ {
+ WithRegion(region, arn);
+ }
+ return this;
+ }
+
+ ///
+ /// Marks a region as broken (will throw errors). The region must already be added.
+ ///
+ public KeyManagementServiceTestBuilder WithBrokenRegion(string region)
+ {
+ _brokenRegions.Add(region);
+ return this;
+ }
+
+ ///
+ /// Marks multiple regions as broken. The regions must already be added.
+ ///
+ public KeyManagementServiceTestBuilder WithBrokenRegions(params string[] regions)
+ {
+ foreach (var region in regions)
+ {
+ _brokenRegions.Add(region);
+ }
+ return this;
+ }
+
+ ///
+ /// Sets a custom logger factory. If not specified, a LoggerFactoryStub will be used.
+ ///
+ public KeyManagementServiceTestBuilder WithLoggerFactory(ILoggerFactory loggerFactory)
+ {
+ _loggerFactory = loggerFactory;
+ return this;
+ }
+
+ ///
+ /// Builds the KeyManagementService with the configured options.
+ ///
+ public KeyManagementService Build()
+ {
+ if (_regionKeyArns.Count == 0)
+ {
+ throw new InvalidOperationException("At least one region must be specified.");
+ }
+
+ // Mark broken regions with "ERROR" ARN
+ var options = new KeyManagementServiceOptions
+ {
+ RegionKeyArns = _regionKeyArns.Select(rka =>
+ new RegionKeyArn
+ {
+ Region = rka.Region,
+ KeyArn = _brokenRegions.Contains(rka.Region) ? "ERROR" : rka.KeyArn
+ }).ToList()
+ };
+
+ var loggerFactory = _loggerFactory ?? new LoggerFactoryStub();
+ var clientFactory = new KeyManagementClientFactoryStub(options);
+
+ return KeyManagementService.NewBuilder()
+ .WithLoggerFactory(loggerFactory)
+ .WithOptions(options)
+ .WithKmsClientFactory(clientFactory)
+ .Build();
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs
new file mode 100644
index 000000000..8367302e3
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs
@@ -0,0 +1,330 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading.Tasks;
+using GoDaddy.Asherah.AppEncryption.Exceptions;
+using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers;
+using GoDaddy.Asherah.Crypto.Engine.BouncyCastle;
+using GoDaddy.Asherah.Crypto.ExtensionMethods;
+using Microsoft.Extensions.Logging;
+using Xunit;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms
+{
+ [ExcludeFromCodeCoverage]
+ public class KeyManagementServiceTests
+ {
+ private const string UsEast1 = "us-east-1";
+ private const string ArnUsEast1 = "arn-us-east-1";
+ private const string UsWest2 = "us-west-2";
+ private const string ArnUsWest2 = "arn-us-west-2";
+ private const string EuWest2 = "eu-west-2";
+ private const string ArnEuWest2 = "arn-eu-west-2";
+
+ [Fact]
+ public async Task EncryptKeyAsync_ShouldEncryptKey()
+ {
+ // Arrange
+ using var service = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var key = crypto.GenerateKey(keyCreationTime);
+
+ // Act
+ var result = await service.EncryptKeyAsync(key);
+
+ // Assert
+ ValidateEncryptedKey(result, UsEast1, UsWest2);
+ }
+
+ [Fact]
+ public void EncryptKey_ShouldEncryptKey()
+ {
+ // Arrange
+ using var service = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var key = crypto.GenerateKey(keyCreationTime);
+
+ // Act
+ var result = service.EncryptKey(key);
+
+ // Assert
+ ValidateEncryptedKey(result, UsEast1, UsWest2);
+ }
+
+ private static void ValidateEncryptedKey(byte[] encryptedKeyResult, params string[] expectedRegions)
+ {
+ Assert.NotNull(encryptedKeyResult);
+
+ // Deserialize and validate JSON structure
+ var jsonNode = System.Text.Json.Nodes.JsonNode.Parse(encryptedKeyResult);
+
+ // Assert JSON structure
+ Assert.NotNull(jsonNode);
+ Assert.True(jsonNode is System.Text.Json.Nodes.JsonObject);
+
+ var jsonObject = jsonNode.AsObject();
+
+ // Assert encryptedKey exists and is not empty
+ Assert.True(jsonObject.ContainsKey("encryptedKey"));
+ var encryptedKey = jsonObject["encryptedKey"];
+ Assert.NotNull(encryptedKey);
+ Assert.True(encryptedKey is System.Text.Json.Nodes.JsonValue);
+ var encryptedKeyValue = encryptedKey!.AsValue().GetValue();
+ Assert.NotNull(encryptedKeyValue);
+ Assert.NotEmpty(encryptedKeyValue);
+
+ // Assert kmsKeks exists and is an array
+ Assert.True(jsonObject.ContainsKey("kmsKeks"));
+ var kmsKeks = jsonObject["kmsKeks"];
+ Assert.NotNull(kmsKeks);
+ Assert.True(kmsKeks is System.Text.Json.Nodes.JsonArray);
+
+ var kmsKeksArray = kmsKeks!.AsArray();
+ Assert.Equal(expectedRegions.Length, kmsKeksArray.Count);
+
+ // Assert each KMS KEK has required properties
+ var actualRegions = new List();
+ foreach (var kekNode in kmsKeksArray)
+ {
+ Assert.NotNull(kekNode);
+ Assert.True(kekNode is System.Text.Json.Nodes.JsonObject);
+
+ var kekObject = kekNode!.AsObject();
+
+ // Assert region exists
+ Assert.True(kekObject.ContainsKey("region"));
+ var region = kekObject["region"];
+ Assert.NotNull(region);
+ var regionValue = region!.AsValue().GetValue();
+ Assert.NotNull(regionValue);
+ actualRegions.Add(regionValue);
+
+ // Assert arn exists
+ Assert.True(kekObject.ContainsKey("arn"));
+ var arn = kekObject["arn"];
+ Assert.NotNull(arn);
+ var arnValue = arn!.AsValue().GetValue();
+ Assert.NotNull(arnValue);
+
+ // Assert encryptedKek exists and is not empty
+ Assert.True(kekObject.ContainsKey("encryptedKek"));
+ var encryptedKek = kekObject["encryptedKek"];
+ Assert.NotNull(encryptedKek);
+ var encryptedKekValue = encryptedKek!.AsValue().GetValue();
+ Assert.NotNull(encryptedKekValue);
+ Assert.NotEmpty(encryptedKekValue);
+ }
+
+ // Assert we have all expected regions
+ foreach (var expectedRegion in expectedRegions)
+ {
+ Assert.Contains(expectedRegion, actualRegions);
+ }
+ }
+
+
+ [Fact]
+ public async Task DecryptKeyAsync_ShouldDecryptKey()
+ {
+ // Arrange
+ using var service = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var originalKey = crypto.GenerateKey(keyCreationTime);
+
+ // Act
+ var encryptedResult = await service.EncryptKeyAsync(originalKey);
+ var decryptedKey = await service.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false);
+
+ // Assert
+ Assert.NotNull(decryptedKey);
+ Assert.Equal(originalKey.WithKey(keyBytes => keyBytes), decryptedKey.WithKey(keyBytes => keyBytes));
+ Assert.Equal(originalKey.GetCreated(), decryptedKey.GetCreated());
+ }
+
+ [Fact]
+ public void DecryptKey_ShouldDecryptKey()
+ {
+ // Arrange
+ using var service = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var originalKey = crypto.GenerateKey(keyCreationTime);
+
+ // Act
+ var encryptedResult = service.EncryptKey(originalKey);
+ var decryptedKey = service.DecryptKey(encryptedResult, keyCreationTime, revoked: false);
+
+ // Assert
+ Assert.NotNull(decryptedKey);
+ Assert.Equal(originalKey.WithKey(keyBytes => keyBytes), decryptedKey.WithKey(keyBytes => keyBytes));
+ Assert.Equal(originalKey.GetCreated(), decryptedKey.GetCreated());
+ }
+
+ [Fact]
+ public async Task DecryptKeyAsync_ShouldDecryptKey_BetweenRegions()
+ {
+ // Arrange
+ using var serviceEast = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .Build();
+ using var serviceWest = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsWest2, ArnUsWest2), (UsEast1, ArnUsEast1))
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var originalKey = crypto.GenerateKey(keyCreationTime);
+
+ // Act - Encrypt with East, decrypt with West
+ var encryptedResult = await serviceEast.EncryptKeyAsync(originalKey);
+ var decryptedKey = await serviceWest.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false);
+
+ // Assert
+ Assert.NotNull(decryptedKey);
+ Assert.Equal(originalKey.WithKey(keyBytes => keyBytes), decryptedKey.WithKey(keyBytes => keyBytes));
+ Assert.Equal(originalKey.GetCreated(), decryptedKey.GetCreated());
+ }
+
+ [Fact]
+ public async Task DecryptKeyAsync_ShouldDecryptKey_BetweenRegions_WhenNewRegionExists()
+ {
+ // Arrange
+ using var serviceEast = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .Build();
+ using var serviceWithAdditionalRegion = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((EuWest2, ArnEuWest2), (UsWest2, ArnUsWest2), (UsEast1, ArnUsEast1))
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var originalKey = crypto.GenerateKey(keyCreationTime);
+
+ // Act - Encrypt with East, decrypt with service that has additional region
+ var encryptedResult = await serviceEast.EncryptKeyAsync(originalKey);
+ var decryptedKey = await serviceWithAdditionalRegion.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false);
+
+ // Assert
+ Assert.NotNull(decryptedKey);
+ Assert.Equal(originalKey.WithKey(keyBytes => keyBytes), decryptedKey.WithKey(keyBytes => keyBytes));
+ Assert.Equal(originalKey.GetCreated(), decryptedKey.GetCreated());
+ }
+
+ [Fact]
+ public async Task DecryptKeyAsync_ShouldFail_BetweenRegions_WhenNoRegionsMatch()
+ {
+ // Arrange
+ using var serviceEast = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .Build();
+ using var serviceOnlyEurope = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((EuWest2, ArnEuWest2))
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var originalKey = crypto.GenerateKey(keyCreationTime);
+
+ // Act - Encrypt with East, decrypt with Europe-only service
+ var encryptedResult = await serviceEast.EncryptKeyAsync(originalKey);
+ var decryptTask = serviceOnlyEurope.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false);
+
+ // Assert
+ await Assert.ThrowsAsync(async () => await decryptTask);
+ }
+
+ [Fact]
+ public void DecryptKey_ShouldDecryptKey_BetweenRegions()
+ {
+ // Arrange
+ using var serviceEast = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .Build();
+ using var serviceWest = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsWest2, ArnUsWest2), (UsEast1, ArnUsEast1))
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var originalKey = crypto.GenerateKey(keyCreationTime);
+
+ // Act - Encrypt with East, decrypt with West
+ var encryptedResult = serviceEast.EncryptKey(originalKey);
+ var decryptedKey = serviceWest.DecryptKey(encryptedResult, keyCreationTime, revoked: false);
+
+ // Assert
+ Assert.NotNull(decryptedKey);
+ Assert.Equal(originalKey.WithKey(keyBytes => keyBytes), decryptedKey.WithKey(keyBytes => keyBytes));
+ Assert.Equal(originalKey.GetCreated(), decryptedKey.GetCreated());
+ }
+
+ [Fact]
+ public async Task EncryptKeyAsync_AllRegionsBroken_ShouldThrowException()
+ {
+ // Arrange
+ using var service = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .WithBrokenRegions(UsEast1, UsWest2)
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var key = crypto.GenerateKey(keyCreationTime);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ await service.EncryptKeyAsync(key));
+ }
+
+ [Fact]
+ public async Task EncryptKeyAsync_SomeRegionsBroken_Succeeds()
+ {
+ // Arrange
+ var loggerFactory = new LoggerFactoryStub();
+ using var service = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .WithBrokenRegion(UsEast1)
+ .WithLoggerFactory(loggerFactory)
+ .Build();
+ using var crypto = new BouncyAes256GcmCrypto();
+ var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1));
+ using var key = crypto.GenerateKey(keyCreationTime);
+
+ // Act
+ var result = await service.EncryptKeyAsync(key);
+
+ // Assert
+ ValidateEncryptedKey(result, UsEast1, UsWest2);
+
+ // Verify that a warning was logged for the failed region
+ var warningLogs = loggerFactory.LogEntries
+ .Where(log => log.LogLevel == LogLevel.Warning)
+ .ToList();
+ Assert.NotEmpty(warningLogs);
+ var failedRegionLog = warningLogs.FirstOrDefault(log =>
+ log.Message.Contains(UsEast1) && log.Message.Contains("Failed to generate data key"));
+ Assert.NotNull(failedRegionLog);
+ Assert.NotNull(failedRegionLog.Exception);
+ }
+
+ [Fact]
+ public async Task EncryptKeyAsync_Fails_WhenCryptoKeyIsNull()
+ {
+ // Arrange
+ using var service = KeyManagementServiceTestBuilder.Create()
+ .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2))
+ .Build();
+
+ // Act & Assert
+ await Assert.ThrowsAsync(async () =>
+ await service.EncryptKeyAsync(null!));
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LogEntry.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LogEntry.cs
new file mode 100644
index 000000000..4e614febc
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LogEntry.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Logging;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers
+{
+ ///
+ /// Record representing a log entry for testing purposes.
+ ///
+ /// The log level of the entry.
+ /// The event ID of the entry.
+ /// The exception associated with the entry, if any.
+ /// The log message.
+ [ExcludeFromCodeCoverage]
+ public record LogEntry(LogLevel LogLevel, EventId EventId, Exception Exception, string Message);
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerFactoryStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerFactoryStub.cs
new file mode 100644
index 000000000..6de475f64
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerFactoryStub.cs
@@ -0,0 +1,35 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Logging;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers
+{
+ ///
+ /// Stub implementation of ILoggerFactory for testing purposes.
+ ///
+ [ExcludeFromCodeCoverage]
+ public class LoggerFactoryStub : ILoggerFactory
+ {
+ private readonly LoggerStub _loggerStub = new LoggerStub();
+
+ ///
+ /// Gets the list of log entries captured by the logger.
+ ///
+ public IReadOnlyList LogEntries => _loggerStub.LogEntries;
+
+ ///
+ public void AddProvider(ILoggerProvider provider)
+ {
+ // Stub implementation - does nothing
+ }
+
+ ///
+ public ILogger CreateLogger(string categoryName) => _loggerStub;
+
+ ///
+ public void Dispose()
+ {
+ // Stub implementation - does nothing
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerStub.cs
new file mode 100644
index 000000000..b11d3c5ed
--- /dev/null
+++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerStub.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.Logging;
+
+namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers
+{
+ ///
+ /// Stub implementation of ILogger for testing purposes.
+ ///
+ [ExcludeFromCodeCoverage]
+ public class LoggerStub : ILogger
+ {
+ private readonly List _logEntries = new List();
+
+ ///
+ /// Gets the list of log entries captured by this logger.
+ ///
+ public IReadOnlyList LogEntries => _logEntries.AsReadOnly();
+
+ ///
+ public IDisposable BeginScope(TState state) => null;
+
+ ///
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ ///
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter)
+ {
+ var message = formatter?.Invoke(state, exception) ?? state?.ToString() ?? string.Empty;
+ _logEntries.Add(new LogEntry(logLevel, eventId, exception, message));
+ }
+ }
+}
diff --git a/csharp/AppEncryption/AppEncryption.Tests/GlobalSuppressions.cs b/csharp/AppEncryption/AppEncryption.Tests/GlobalSuppressions.cs
deleted file mode 100644
index 126767a82..000000000
--- a/csharp/AppEncryption/AppEncryption.Tests/GlobalSuppressions.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-// This file is used by Code Analysis to maintain SuppressMessage
-// attributes that are applied to this project.
-// Project-level suppressions either have no target or are given
-// a specific target and scoped to a namespace, type, member, etc.
-
-using System.Diagnostics.CodeAnalysis;
-
-[assembly: SuppressMessage("Design", "CA2201:Do not raise reserved exception types", Justification = "Test methods may use SystemException for testing purposes", Scope = "module")]
-[assembly: SuppressMessage("Design", "CA1816:Call GC.SuppressFinalize correctly", Justification = "Test classes do not need to call GC.SuppressFinalize.")]
-[assembly: SuppressMessage("Naming", "CA1707:Identifiers should not contain underscores", Justification = "Test method names commonly use underscores for readability", Scope = "module")]
diff --git a/csharp/AppEncryption/AppEncryption.slnx b/csharp/AppEncryption/AppEncryption.slnx
index 638a093a1..7d2054458 100644
--- a/csharp/AppEncryption/AppEncryption.slnx
+++ b/csharp/AppEncryption/AppEncryption.slnx
@@ -2,5 +2,6 @@
+
diff --git a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj
index 0004d690a..f44209116 100644
--- a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj
+++ b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj
@@ -13,7 +13,7 @@
true
Recommended
true
- $(NoWarn);CA1711;CA1873
+ $(NoWarn);CA1711;CA1873;CA1848
False
https://github.com/godaddy/asherah
@@ -24,8 +24,8 @@
-
-
+
+
diff --git a/csharp/AppEncryption/AppEncryption/GlobalSuppressions.cs b/csharp/AppEncryption/AppEncryption/GlobalSuppressions.cs
deleted file mode 100644
index 85f18dcc3..000000000
--- a/csharp/AppEncryption/AppEncryption/GlobalSuppressions.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-// This file is used by Code Analysis to suppress warnings
-// that are generated by the build process.
-// See https://learn.microsoft.com/dotnet/fundamentals/code-analysis/configuration-files#global-suppressions
-
-using System.Diagnostics.CodeAnalysis;
-
-[assembly: SuppressMessage("Performance", "CA1848:Use LoggerMessage delegates", Justification = "Debug logging messages don't require the performance improvement from LoggerMessage delegates")]
-[assembly: SuppressMessage("CodeQuality", "CA1510:Use ArgumentNullException.ThrowIfNull", Justification = "ArgumentNullException.ThrowIfNull is not available in netstandard2.0")]
diff --git a/csharp/AppEncryption/AppEncryption/Kms/AwsKeyManagementServiceImpl.cs b/csharp/AppEncryption/AppEncryption/Kms/AwsKeyManagementServiceImpl.cs
index ccc89be3c..e4bf6cdcc 100644
--- a/csharp/AppEncryption/AppEncryption/Kms/AwsKeyManagementServiceImpl.cs
+++ b/csharp/AppEncryption/AppEncryption/Kms/AwsKeyManagementServiceImpl.cs
@@ -42,6 +42,7 @@ namespace GoDaddy.Asherah.AppEncryption.Kms
/// }
///
///
+ [Obsolete("Use the Kms.KeyManagementService from the GoDaddy.Asherah.AppEncryption.PlugIns.Aws package instead. This will be removed in a future release.")]
public class AwsKeyManagementServiceImpl : KeyManagementService
{
internal const string EncryptedKey = "encryptedKey";
diff --git a/csharp/AppEncryption/README.md b/csharp/AppEncryption/README.md
index 1fe02e6af..7a366c4e8 100644
--- a/csharp/AppEncryption/README.md
+++ b/csharp/AppEncryption/README.md
@@ -19,28 +19,30 @@ Application level envelope encryption SDK for C# with support for cloud-agnostic
* [Development Notes](#development-notes)
## Installation
-You can get the latest release from [Nuget](https://www.nuget.org/packages/GoDaddy.Asherah.AppEncryption/):
+You can get the latest releases from NuGet.
+- Main library: [GoDaddy.Asherah.AppEncryption](https://www.nuget.org/packages/GoDaddy.Asherah.AppEncryption/)
+- If using AWS implementations: [GoDaddy.Asherah.AppEncryption.PlugIns.Aws](https://www.nuget.org/packages/GoDaddy.Asherah.AppEncryption.PlugIns.Aws/)
```xml
-
+
+
```
-`GoDaddy.Asherah.AppEncryption` targets NetStandard 2.0 and NetStandard 2.1. See the
-[.NET Standard documentation](https://docs.microsoft.com/en-us/dotnet/standard/net-standard) and
-[Multi-targeting](https://docs.microsoft.com/en-us/dotnet/standard/library-guidance/cross-platform-targeting#multi-targeting)
-for more information.
+Our libraries currently target netstandard2.0, net8.0, net9.0.
## Quick Start
```c#
// Create a session factory. The builder steps used below are for testing only.
+var staticKeyManagementService = new StaticKeyManagementServiceImpl("thisIsAStaticMasterKeyForTestingOnly");
+
using (SessionFactory sessionFactory = SessionFactory
.NewBuilder("some_product", "some_service")
.WithMemoryPersistence()
.WithNeverExpiredCryptoPolicy()
- .WithStaticKeyManagementService("thisIsAStaticMasterKeyForTesting")
+ .WithKeyManagementService(staticKeyManagementService)
.Build())
{
// Now create a cryptographic session for a partition.
@@ -113,7 +115,7 @@ IMetastore dynamoDbMetastore = DynamoDbMetastoreImpl.NewBuilder("us-wes
.Build();
```
-**Recommended: Using WithDynamoDbClient with Dependency Injection and AWSSDK.Extensions.NETCore.Setup**
+**Recommended: Using WithDynamoDbClient with IServicesCollection and AWSSDK.Extensions.NETCore.Setup**
```c#
// In your DI container setup (e.g., Startup.cs, Program.cs)
@@ -167,27 +169,83 @@ Detailed information about the Key Management Service can be found [here](../../
#### AWS KMS
-Create a dictionary of region and ARN pairs that will all be used when creating a System Key
+> [!NOTE]
+> This section now covers using the recommended GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms.KeyManagementService
+> The GoDaddy.Asherah.AppEncryption.Kms.AwsKeyManagementServiceImpl is obsolete and will be removed in the future.
+> See the [Plugins Upgrade Guide](docs/plugins-upgrade-guide.md) for migration instructions.
+
+One way to create your Key Management Service is to use the builder, use the static factory method `NewBuilder`. Provide an ILoggerFactory, your region key arns and AWS credentials. A good strategy if using multiple regions is to provide the closest regions first based on what region your app is running in.
```c#
-Dictionary regionDictionary = new Dictionary
+var keyManagementService = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(myLoggerFactory) // required
+ .WithRegionKeyArn("us-east-1", "arn:aws:kms:us-east-1:123456789012:key/abc") // add these in priority order
+ .WithRegionKeyArn("us-west-2", "arn:aws:kms:us-west-2:234567890123:key/def")
+ .WithCredentials(myAwsCredentials)
+ .Build()
+```
+
+Other options when using the builder are:
+
+- **WithOptions**: Provide a KeyManagementServiceOptions that contains the RegionKeyArns instead of WithRegionKeyArn.
+- **WithKmsClientFactory**: Provide your own client factory instead of using WithCredentials.
+
+The KeyManagementServiceOptions is easily deserializable from appsettings/configuration
+You can implement your own IKeyManagementClientFactory if you need better control how your AWS Kms Clients are created.
+
+**Recommended: Setting up with IServicesCollection and AWSSDK.Extensions.NETCore.Setup**
+
+```json
{
- { "us-east-1", "arn_of_us-east-1" },
- { "us-east-2", "arn_of_us-east-2" },
- ...
-};
+ "AsherahKmsOptions": {
+ "regionKeyArns": [
+ {
+ "region": "us-west-2",
+ "keyArn": "Key Arn for us-west-2"
+ },
+ {
+ "region": "us-east-1",
+ "keyArn": "Key Arn for us-east-1"
+ }
+ ]
+ }
+}
```
-To obtain an instance of the builder, use the static factory method `NewBuilder`. Provide the region dictionary and your preferred (usually current) region.
+Then, in code:
```c#
-KeyManagementService keyManagementService = AwsKeyManagementServiceImpl.NewBuilder(regionDictionary, "us-east-1");
-```
+// In your DI container setup (e.g., Startup.cs, Program.cs)
+// assumes you also have setup ILoggerFactory
+services.AddDefaultAWSOptions(Configuration.GetAWSOptions());
-Once you have a builder, you can either use the `WithXXX` setter methods to configure any additional properties or simply
-build the Key Management Service by calling the `Build` method.
+var kmsOptions = Configuration.GetValue("AsherahKmsOptions");
+services.AddSingleton(kmsOptions);
- - **WithCredentials**: Specifies custom credentials for the AWS KMS client.
+// Then later in a class
+public class MyService(AWSOptions awsOptions, KeyManagementServiceOptions kmsOptions, ILoggerFactory loggerFactory)
+{
+ public IKeyManagementService CreateKeyManagementServiceForAsherah(){
+
+ // Optimize KMS options to prioritize the current AWS region
+ // This is optional. If your kmsOptions are not configured by region, you would use this
+ // to do a runtime sort based on the current running region.
+ // Note: this example is simply putting the current region first and leaving the sequence of the
+ // remaining in the order they were in.
+ var optimizedKmsOptions = kmsOptions.OptimizeByRegions(awsOptions.Region.SystemName);
+
+ var keyManagementService = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(loggerFactory)
+ .WithOptions(optimizedKmsOptions)
+ .WithCredentials(awsOptions.GetCredentials())
+ .Build();
+
+ // return the keyManagementService that can be passed into the SessionFactory builder
+ return keyManagementService;
+ }
+
+}
+```
#### Static KMS (FOR TESTING ONLY)
diff --git a/csharp/AppEncryption/docs/plugins-upgrade-guide.md b/csharp/AppEncryption/docs/plugins-upgrade-guide.md
new file mode 100644
index 000000000..22382eee8
--- /dev/null
+++ b/csharp/AppEncryption/docs/plugins-upgrade-guide.md
@@ -0,0 +1,166 @@
+# Plugins Upgrade Guide
+
+This guide provides step-by-step instructions for upgrading from obsolete plugin implementations to the new recommended plugins.
+
+## Table of Contents
+
+- [Upgrading to the new KeyManagementService Plugin](#upgrading-to-the-new-keymanagementservice-plugin)
+
+## Upgrading to the new KeyManagementService Plugin
+
+The `AwsKeyManagementServiceImpl` class is obsolete and will be removed in a future version. This guide will help you migrate to the new `KeyManagementService` plugin.
+
+### Key Changes
+
+1. **Namespace**: Changed from `GoDaddy.Asherah.AppEncryption.Kms` to `GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms`
+2. **Logger**: Now requires `ILoggerFactory` instead of `ILogger`
+3. **Region/ARN Configuration**: Dictionary-based approach replaced with builder pattern or configuration-based options
+4. **Preferred Region**: No longer a constructor parameter; handled via region ordering or `OptimizeByRegions()` method
+
+### Migration Steps
+
+#### Step 1: Update Namespace
+
+**Old:**
+```c#
+using GoDaddy.Asherah.AppEncryption.Kms;
+```
+
+**New:**
+```c#
+using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms;
+```
+
+#### Step 2: Convert Dictionary to New Format
+
+**Old Approach:**
+```c#
+Dictionary regionDictionary = new Dictionary
+{
+ { "us-east-1", "arn_of_us-east-1" },
+ { "us-east-2", "arn_of_us-east-2" },
+ { "us-west-2", "arn_of_us-west-2" }
+};
+
+KeyManagementService keyManagementService = AwsKeyManagementServiceImpl.NewBuilder(regionDictionary, "us-east-1")
+ .WithCredentials(myAwsCredentials)
+ .WithLogger(myLogger)
+ .Build();
+```
+
+**New Approach - Using Builder Pattern:**
+```c#
+var keyManagementService = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(loggerFactory) // Required - note: ILoggerFactory, not ILogger
+ .WithRegionKeyArn("us-east-1", "arn_of_us-east-1") // Preferred region first
+ .WithRegionKeyArn("us-east-2", "arn_of_us-east-2")
+ .WithRegionKeyArn("us-west-2", "arn_of_us-west-2")
+ .WithCredentials(myAwsCredentials)
+ .Build();
+```
+
+**New Approach - Using Configuration (Recommended):**
+
+First, add to your configuration (appsettings.json, etc.):
+```json
+{
+ "AsherahKmsOptions": {
+ "regionKeyArns": [
+ {
+ "region": "us-east-1",
+ "keyArn": "arn_of_us-east-1"
+ },
+ {
+ "region": "us-east-2",
+ "keyArn": "arn_of_us-east-2"
+ },
+ {
+ "region": "us-west-2",
+ "keyArn": "arn_of_us-west-2"
+ }
+ ]
+ }
+}
+```
+
+Then in code:
+```c#
+// In DI setup
+var kmsOptions = Configuration.GetValue("AsherahKmsOptions");
+services.AddSingleton(kmsOptions);
+
+// Later in your service
+var keyManagementService = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(loggerFactory)
+ .WithOptions(kmsOptions)
+ .WithCredentials(awsOptions.GetCredentials())
+ .Build();
+```
+
+#### Step 3: Handle Preferred Region
+
+The old API took a preferred region as a constructor parameter. In the new API, you have two options:
+
+**Option A: Order regions in your configuration/builder calls**
+Simply place your preferred region first in the list. The first region will be tried first for data key generation.
+
+**Option B: Use `OptimizeByRegions()` for runtime prioritization**
+If you need to prioritize based on the current runtime region, use the `OptimizeByRegions()` method:
+
+```c#
+// Prioritize based on current AWS region at runtime
+var optimizedKmsOptions = kmsOptions.OptimizeByRegions(awsOptions.Region.SystemName);
+
+var keyManagementService = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(loggerFactory)
+ .WithOptions(optimizedKmsOptions)
+ .WithCredentials(awsOptions.GetCredentials())
+ .Build();
+```
+
+#### Step 4: Update Logger Usage
+
+**Old:**
+```c#
+.WithLogger(myLogger) // ILogger
+```
+
+**New:**
+```c#
+.WithLoggerFactory(loggerFactory) // ILoggerFactory - required
+```
+
+If you're using dependency injection, ensure `ILoggerFactory` is registered in your service container.
+
+### Complete Migration Example
+
+**Before:**
+```c#
+Dictionary regionDictionary = new Dictionary
+{
+ { "us-east-1", "arn:aws:kms:us-east-1:123456789012:key/abc" },
+ { "us-west-2", "arn:aws:kms:us-west-2:234567890123:key/def" }
+};
+
+var keyManagementService = AwsKeyManagementServiceImpl.NewBuilder(regionDictionary, "us-east-1")
+ .WithCredentials(credentials)
+ .WithLogger(logger)
+ .Build();
+```
+
+**After:**
+```c#
+var keyManagementService = KeyManagementService.NewBuilder()
+ .WithLoggerFactory(loggerFactory)
+ .WithRegionKeyArn("us-east-1", "arn:aws:kms:us-east-1:123456789012:key/abc")
+ .WithRegionKeyArn("us-west-2", "arn:aws:kms:us-west-2:234567890123:key/def")
+ .WithCredentials(credentials)
+ .Build();
+```
+
+### Additional Notes
+
+- The new `KeyManagementService` implements `IKeyManagementService`, so it's a drop-in replacement for `AwsKeyManagementServiceImpl`
+- Both synchronous and asynchronous methods are available (`EncryptKey`/`EncryptKeyAsync`, `DecryptKey`/`DecryptKeyAsync`)
+- The new implementation provides better support for dependency injection and configuration-based setup
+- Region fallback behavior remains the same - if the first region fails, it will automatically try the next region in the list
diff --git a/samples/csharp/ReferenceApp/ReferenceApp.sln.DotSettings b/samples/csharp/ReferenceApp/ReferenceApp.sln.DotSettings
deleted file mode 100644
index ea08e4651..000000000
--- a/samples/csharp/ReferenceApp/ReferenceApp.sln.DotSettings
+++ /dev/null
@@ -1,4 +0,0 @@
-
- True
- <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" />
- <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" />
diff --git a/tartufo.toml b/tartufo.toml
index 7aafaa998..029f2ab05 100644
--- a/tartufo.toml
+++ b/tartufo.toml
@@ -52,6 +52,10 @@ exclude-signatures = [
{ signature = "e346648381b2a4869f25c33f3de8e75df24957f6bee12090334251e2bca10873", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"},
{ signature = "24612c6eae3c6f9edeb76693844f96b5b4799c4252b155639907dc6e900a7c93", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"},
{ signature = "a4ea5817e072966bb7bfd712b89687465641b1ba58fba56eb35ef62755c0a5bd", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"},
+ { signature = "299353a36f065d32601c2ce91e9a705d0ab16457c74e227e0fe34e82e42153ca", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"},
+ { signature = "cf90106ca7fcc09ff9d8f07a2ea8f9f02a0758f26031808d1cc1f4d38c712f58", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"},
+ { signature = "5923f959a8738aacb46e92db00f78d64eaa0121fe33edbf3571d676f1355d08c", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"},
+ { signature = "4244fda6859ee07323c9787a7fd65a7afb3f001019ca5a362b77a0a1fb63ccda", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"},
{ signature = "0d4a2e3037d931239373f7f547542616cc64fc583072029e6b3a2e77e8b7f89e", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in Go Tests"},
{ signature = "3b43730726f7dd09bb41870ddb5b65e77b6b2b8aab52f40e9237e6d2423cfcb3", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in Go Tests"},