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"},