From d1a2cda97e1b16c659ec858a263488cff22e5cbf Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 11 Oct 2025 10:52:57 -0700 Subject: [PATCH 1/8] Make a new KeyManagementService that is moved into an Aws PlugIns library and obsolete the implementation that is in the main AppEncryption library --- .../AppEncryption.IntegrationTests.csproj | 11 +- .../GlobalSuppressions.cs | 9 - .../AppEncryption.PlugIns.Aws.csproj | 35 ++ .../Kms/IKeyManagementClientFactory.cs | 17 + .../Kms/IKeyManagementServiceBuilder.cs | 58 ++ .../Kms/KeyManagementClientFactory.cs | 48 ++ .../Kms/KeyManagementService.cs | 328 ++++++++++++ .../Kms/KeyManagementServiceBuilder.cs | 98 ++++ .../Kms/KeyManagementServiceOptions.cs | 17 + .../Kms/KmsArnClient.cs | 38 ++ .../Kms/RegionKeyArn.cs | 22 + .../AppEncryption.Tests.csproj | 72 +-- .../Kms/AwsKeyManagementServiceImplTest.cs | 43 ++ .../PlugIns/Aws/Kms/AwsKeyManagementStub.cs | 495 ++++++++++++++++++ .../Aws/Kms/KeyManagementClientFactoryStub.cs | 45 ++ .../Kms/KeyManagementClientFactoryTests.cs | 49 ++ .../Kms/KeyManagementServiceBuilderTests.cs | 107 ++++ .../Aws/Kms/KeyManagementServiceTests.cs | 394 ++++++++++++++ .../AppEncryption/TestHelpers/LogEntry.cs | 16 + .../TestHelpers/LoggerFactoryStub.cs | 35 ++ .../AppEncryption/TestHelpers/LoggerStub.cs | 34 ++ .../AppEncryption.Tests/GlobalSuppressions.cs | 10 - csharp/AppEncryption/AppEncryption.slnx | 1 + .../AppEncryption/AppEncryption.csproj | 16 +- .../AppEncryption/GlobalSuppressions.cs | 8 - .../Kms/AwsKeyManagementServiceImpl.cs | 1 + csharp/AppEncryption/Crypto/Crypto.csproj | 4 +- csharp/AppEncryption/README.md | 86 ++- .../ReferenceApp/ReferenceApp.sln.DotSettings | 4 - tartufo.toml | 4 + 30 files changed, 2004 insertions(+), 101 deletions(-) delete mode 100644 csharp/AppEncryption/AppEncryption.IntegrationTests/GlobalSuppressions.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementClientFactory.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementServiceBuilder.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementClientFactory.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementService.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceBuilder.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptions.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KmsArnClient.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/RegionKeyArn.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/AwsKeyManagementStub.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryStub.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryTests.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceBuilderTests.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LogEntry.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerFactoryStub.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerStub.cs delete mode 100644 csharp/AppEncryption/AppEncryption.Tests/GlobalSuppressions.cs delete mode 100644 csharp/AppEncryption/AppEncryption/GlobalSuppressions.cs delete mode 100644 samples/csharp/ReferenceApp/ReferenceApp.sln.DotSettings diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index 922325087..9f60ba797 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -1,26 +1,27 @@ - net9.0 + net9.0 false GoDaddy.Asherah.AppEncryption.IntegrationTests true true Recommended true + CS0618;CA1816;CA1848 - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - + 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..299144b30 --- /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;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/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 ab9784a1d..ade9f7d11 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -1,37 +1,39 @@ - - net9.0 - false - GoDaddy.Asherah.AppEncryption.Tests - true - true - Recommended - true - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + net9.0 + false + GoDaddy.Asherah.AppEncryption.Tests + true + true + Recommended + true + CS0618;CA1707;CA1816;CA2201 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + 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/KeyManagementServiceTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs new file mode 100644 index 000000000..31ef3f2b9 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs @@ -0,0 +1,394 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Exceptions; +using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms; +using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers; +using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; +using GoDaddy.Asherah.Crypto.ExtensionMethods; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms +{ + [ExcludeFromCodeCoverage] + public class KeyManagementServiceTests : IDisposable + { + 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"; + + private readonly KeyManagementService _keyManagementServiceEast; + private readonly KeyManagementService _keyManagementServiceWest; + private readonly KeyManagementService _keyManagementServiceAdditionalRegion; + private readonly KeyManagementService _keyManagementServiceSomeBroken; + private readonly KeyManagementService _keyManagementServiceAllBroken; + private readonly KeyManagementService _keyManagementServiceOnlyEurope; + + public KeyManagementServiceTests() + { + var optionsEast = new KeyManagementServiceOptions + { + RegionKeyArns = + [ + new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 }, + new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 } + ] + }; + + var optionsWest = new KeyManagementServiceOptions + { + RegionKeyArns = + [ + new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 }, + new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 } + ] + }; + + var optionsEurope = new KeyManagementServiceOptions + { + RegionKeyArns = + [ + new RegionKeyArn { Region = EuWest2, KeyArn = ArnEuWest2 }, + new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 }, + new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 } + ] + }; + + var optionsOnlyEurope = new KeyManagementServiceOptions + { + RegionKeyArns = + [ + new RegionKeyArn { Region = EuWest2, KeyArn = ArnEuWest2 } + ] + }; + + var optionsSomeBroken = new KeyManagementServiceOptions + { + RegionKeyArns = + [ + new RegionKeyArn { Region = UsEast1, KeyArn = "ERROR" }, + new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 } + ] + }; + + var optionsAllBroken = new KeyManagementServiceOptions + { + RegionKeyArns = + [ + new RegionKeyArn { Region = UsEast1, KeyArn = "ERROR" }, + new RegionKeyArn { Region = UsWest2, KeyArn = "ERROR" } + ] + }; + + var loggerFactoryStub = new LoggerFactoryStub(); + var clientFactoryStub = new KeyManagementClientFactoryStub(optionsEast); + + _keyManagementServiceEast = KeyManagementService.NewBuilder() + .WithLoggerFactory(loggerFactoryStub) + .WithOptions(optionsEast) + .WithKmsClientFactory(clientFactoryStub) + .Build(); + + _keyManagementServiceWest = KeyManagementService.NewBuilder() + .WithLoggerFactory(loggerFactoryStub) + .WithOptions(optionsWest) + .WithKmsClientFactory(clientFactoryStub) + .Build(); + + var clientFactoryStubSomeBroken = new KeyManagementClientFactoryStub(optionsSomeBroken); + _keyManagementServiceSomeBroken = KeyManagementService.NewBuilder() + .WithLoggerFactory(loggerFactoryStub) + .WithOptions(optionsSomeBroken) + .WithKmsClientFactory(clientFactoryStubSomeBroken) + .Build(); + + var clientFactoryStubAllBroken = new KeyManagementClientFactoryStub(optionsAllBroken); + _keyManagementServiceAllBroken = KeyManagementService.NewBuilder() + .WithLoggerFactory(loggerFactoryStub) + .WithOptions(optionsAllBroken) + .WithKmsClientFactory(clientFactoryStubAllBroken) + .Build(); + + var clientFactoryStubEurope = new KeyManagementClientFactoryStub(optionsEurope); + _keyManagementServiceAdditionalRegion = KeyManagementService.NewBuilder() + .WithLoggerFactory(loggerFactoryStub) + .WithOptions(optionsEurope) + .WithKmsClientFactory(clientFactoryStubEurope) + .Build(); + + var clientFactoryStubOnlyEurope = new KeyManagementClientFactoryStub(optionsOnlyEurope); + _keyManagementServiceOnlyEurope = KeyManagementService.NewBuilder() + .WithLoggerFactory(loggerFactoryStub) + .WithOptions(optionsOnlyEurope) + .WithKmsClientFactory(clientFactoryStubOnlyEurope) + .Build(); + } + + [Fact] + public async Task EncryptKeyAsync_ShouldEncryptKey() + { + // Arrange + using var crypto = new BouncyAes256GcmCrypto(); + var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1)); + using var key = crypto.GenerateKey(keyCreationTime); + + // Act + var result = await _keyManagementServiceEast.EncryptKeyAsync(key); + + // Assert + ValidateEncryptedKey(result); + } + + [Fact] + public async Task EncryptKeyAsync_RegionFallback_Succeeds() + { + // Arrange + using var crypto = new BouncyAes256GcmCrypto(); + var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1)); + using var key = crypto.GenerateKey(keyCreationTime); + + // Act + var result = await _keyManagementServiceEast.EncryptKeyAsync(key); + + // Assert + ValidateEncryptedKey(result); + } + + [Fact] + public void EncryptKey_ShouldEncryptKey() + { + // Arrange + using var crypto = new BouncyAes256GcmCrypto(); + var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1)); + using var key = crypto.GenerateKey(keyCreationTime); + + // Act + var result = _keyManagementServiceEast.EncryptKey(key); + + // Assert + ValidateEncryptedKey(result); + } + + private static void ValidateEncryptedKey(byte[] encryptedKeyResult) + { + 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(2, kmsKeksArray.Count); // Should have 2 regions + + // Assert each KMS KEK has required properties + 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); + Assert.True(regionValue == "us-east-1" || regionValue == "us-west-2"); + + // Assert arn exists + Assert.True(kekObject.ContainsKey("arn")); + var arn = kekObject["arn"]; + Assert.NotNull(arn); + var arnValue = arn!.AsValue().GetValue(); + Assert.NotNull(arnValue); + Assert.True(arnValue == "arn-us-east-1" || arnValue == "arn-us-west-2" || arnValue == "ERROR"); + + // 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 both regions + var regions = kmsKeksArray.Select(kek => kek!.AsObject()["region"]!.AsValue().GetValue()).ToList(); + Assert.Contains("us-east-1", regions); + Assert.Contains("us-west-2", regions); + } + + + [Fact] + public async Task DecryptKeyAsync_ShouldDecryptKey() + { + // Arrange + using var crypto = new BouncyAes256GcmCrypto(); + var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1)); + using var originalKey = crypto.GenerateKey(keyCreationTime); + + // Act + var encryptedResult = await _keyManagementServiceEast.EncryptKeyAsync(originalKey); + var decryptedKey = await _keyManagementServiceEast.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 crypto = new BouncyAes256GcmCrypto(); + var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1)); + using var originalKey = crypto.GenerateKey(keyCreationTime); + + // Act + var encryptedResult = _keyManagementServiceEast.EncryptKey(originalKey); + var decryptedKey = _keyManagementServiceEast.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 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 _keyManagementServiceEast.EncryptKeyAsync(originalKey); + var decryptedKey = await _keyManagementServiceWest.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 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 _keyManagementServiceEast.EncryptKeyAsync(originalKey); + var decryptedKey = await _keyManagementServiceAdditionalRegion.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 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 _keyManagementServiceEast.EncryptKeyAsync(originalKey); + var decryptTask = _keyManagementServiceOnlyEurope.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false); + + // Assert + await Assert.ThrowsAsync(async () => await decryptTask); + } + + [Fact] + public void DecryptKey_ShouldDecryptKey_BetweenRegions() + { + // Arrange + 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 = _keyManagementServiceEast.EncryptKey(originalKey); + var decryptedKey = _keyManagementServiceWest.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 crypto = new BouncyAes256GcmCrypto(); + var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1)); + using var key = crypto.GenerateKey(keyCreationTime); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await _keyManagementServiceAllBroken.EncryptKeyAsync(key)); + } + + [Fact] + public async Task EncryptKeyAsync_SomeRegionsBroken_Succeeds() + { + // Arrange + using var crypto = new BouncyAes256GcmCrypto(); + var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1)); + using var key = crypto.GenerateKey(keyCreationTime); + + // Act + var result = await _keyManagementServiceSomeBroken.EncryptKeyAsync(key); + + // Assert + ValidateEncryptedKey(result); + } + + [Fact] + public async Task EncryptKeyAsync_Fails_WhenCryptoKeyIsNull() + { + await Assert.ThrowsAsync(async () => + await _keyManagementServiceEast.EncryptKeyAsync(null!)); + } + + public void Dispose() + { + _keyManagementServiceEast?.Dispose(); + _keyManagementServiceWest?.Dispose(); + } + } +} 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 203e2afda..d726901d5 100644 --- a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj +++ b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj @@ -13,7 +13,7 @@ true Recommended true - $(NoWarn);CA1711 + CA1510;CA1711;CA1848 False https://github.com/godaddy/asherah @@ -24,15 +24,15 @@ - - + + - - - + + + - - + + 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/Crypto/Crypto.csproj b/csharp/AppEncryption/Crypto/Crypto.csproj index 63f375fd6..5337aa818 100644 --- a/csharp/AppEncryption/Crypto/Crypto.csproj +++ b/csharp/AppEncryption/Crypto/Crypto.csproj @@ -23,7 +23,7 @@ - - + + diff --git a/csharp/AppEncryption/README.md b/csharp/AppEncryption/README.md index 1fe02e6af..fb4797832 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,71 @@ 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 + +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 preferred 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 +// In your DI container setup (e.g., Startup.cs, Program.cs) +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) +{ + var keyManagementService = KeyManagementService.NewBuilder() + .WithLoggerFactory(loggerFactory) + .WithOptions(kmsOptions) + .WithCredentials(awsOptions.GetCredentials()) + .Build(); + + // pass keyManagementService to the SessionFactory builder +} +``` #### Static KMS (FOR TESTING ONLY) 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"}, From 88b86a8604de97d94d9ee5c3abcb8d21274e5be0 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 15 Nov 2025 06:43:26 -0700 Subject: [PATCH 2/8] Apply pre-commit formatting fixes --- .../AppEncryption.IntegrationTests.csproj | 2 +- .../ConfigFixture.cs | 2 + .../AppEncryptionParameterizedTest.cs | 1 + .../AppEncryption.PlugIns.Aws.csproj | 8 +- .../AppEncryption.Tests.csproj | 3 +- .../Kms/AwsKeyManagementServiceImplTest.cs | 25 ++++++ .../AppEncryption/AppEncryption.csproj | 4 +- csharp/AppEncryption/Crypto/Crypto.csproj | 2 +- .../AppEncryption/package-updates-report.md | 78 +++++++++++++++++++ 9 files changed, 116 insertions(+), 9 deletions(-) create mode 100644 csharp/AppEncryption/package-updates-report.md diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index 0351a4fe8..c31d5f7b8 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -10,7 +10,7 @@ $(NoWarn);CA1873 - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs index 0c8f8d96e..fa56430ac 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs @@ -19,6 +19,7 @@ public class ConfigFixture private static readonly char[] SplitChars = { ',' }; private readonly IConfigurationRoot config; + [Obsolete] public ConfigFixture() { // Load the config file name from environment variables. If not found, default to config.yaml @@ -98,6 +99,7 @@ private IMetastore CreateMetastore() return new InMemoryMetastoreImpl(); } + [Obsolete] private KeyManagementService CreateKeyManagementService() { if (KmsType.Equals(KeyManagementAws, StringComparison.OrdinalIgnoreCase)) diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs index 8adaea560..3d7a80373 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs @@ -173,6 +173,7 @@ private sealed class AppEncryptionParameterizedTestData : IEnumerable private static readonly Random Random = new Random(); private readonly ConfigFixture configFixture; + [Obsolete] public AppEncryptionParameterizedTestData() { configFixture = new ConfigFixture(); diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj index 299144b30..217996d41 100644 --- a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj @@ -5,7 +5,7 @@ GoDaddy GoDaddy AWS extensions for Application level envelope encryption SDK for C# - net8.0;net9.0;netstandard2.0 + net8.0;net9.0;net10.0;netstandard2.0 GoDaddy.Asherah.AppEncryption.PlugIns.Aws @@ -24,9 +24,9 @@ - - - + + + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index da5d3a337..8b84ac462 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -9,7 +9,7 @@ true - + all @@ -30,5 +30,6 @@ + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs index d8764f6ac..a119ee74f 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs @@ -50,8 +50,10 @@ public class AwsKeyManagementServiceImplTest : IClassFixture private readonly Mock cryptoMock; private readonly Mock awsKmsClientFactoryMock; private readonly Mock cryptoKeyMock; + [Obsolete] private readonly Mock awsKeyManagementServiceImplSpy; + [Obsolete] public AwsKeyManagementServiceImplTest() { amazonKeyManagementServiceClientMock = new Mock(); @@ -73,6 +75,7 @@ public AwsKeyManagementServiceImplTest() } [Fact] + [Obsolete] public void TestRegionToArnAndClientDictionaryGeneration() { var mockLogger = new Mock(); @@ -92,6 +95,7 @@ public void TestRegionToArnAndClientDictionaryGeneration() } [Fact] + [Obsolete] public void TestDecryptKeySuccessful() { byte[] encryptedKey = { 0, 1 }; @@ -131,6 +135,7 @@ public void TestDecryptKeySuccessful() } [Fact] + [Obsolete] public async Task TestDecryptKeyAsyncSuccessful() { byte[] encryptedKey = { 0, 1 }; @@ -170,6 +175,7 @@ public async Task TestDecryptKeyAsyncSuccessful() [Fact] + [Obsolete] public void TestDecryptKeyWithMissingRegionInPayloadShouldSkipAndSucceed() { byte[] encryptedKey = { 0, 1 }; @@ -215,6 +221,7 @@ public void TestDecryptKeyWithMissingRegionInPayloadShouldSkipAndSucceed() } [Fact] + [Obsolete] public void TestDecryptKeyWithKmsFailureShouldThrowKmsException() { byte[] encryptedKey = { 0, 1 }; @@ -249,6 +256,7 @@ public void TestDecryptKeyWithKmsFailureShouldThrowKmsException() } [Fact] + [Obsolete] public void TestGetPrioritizedKmsRegionKeyJsonList() { string json = @@ -266,6 +274,7 @@ public void TestGetPrioritizedKmsRegionKeyJsonList() } [Fact] + [Obsolete] public void TestDecryptKmsEncryptedKeySuccessful() { byte[] cipherText = { 0, 1 }; @@ -295,6 +304,7 @@ public void TestDecryptKmsEncryptedKeySuccessful() } [Fact] + [Obsolete] public void TestDecryptKmsEncryptedKeyWithKmsFailureShouldThrowException() { byte[] cipherText = { 0, 1 }; @@ -313,6 +323,7 @@ public void TestDecryptKmsEncryptedKeyWithKmsFailureShouldThrowException() } [Fact] + [Obsolete] public void TestDecryptKmsEncryptedKeyWithCryptoFailureShouldThrowExceptionAndWipeBytes() { byte[] cipherText = { 0, 1 }; @@ -342,6 +353,7 @@ public void TestDecryptKmsEncryptedKeyWithCryptoFailureShouldThrowExceptionAndWi } [Fact] + [Obsolete] public void TestPrimaryBuilderPath() { AwsKeyManagementServiceImpl.Builder awsKeyManagementServicePrimaryBuilder = @@ -351,6 +363,7 @@ public void TestPrimaryBuilderPath() } [Fact] + [Obsolete] public void TestBuilderPathWithCredentials() { AwsKeyManagementServiceImpl.Builder awsKeyManagementServicePrimaryBuilder = @@ -362,6 +375,7 @@ public void TestBuilderPathWithCredentials() } [Fact] + [Obsolete] public void TestBuilderPathWithLoggerEnabled() { var mockLogger = new Mock(); @@ -374,6 +388,7 @@ public void TestBuilderPathWithLoggerEnabled() } [Fact] + [Obsolete] public void TestBuilderPathWithLoggerDisabled() { AwsKeyManagementServiceImpl.Builder awsKeyManagementServicePrimaryBuilder = @@ -383,6 +398,7 @@ public void TestBuilderPathWithLoggerDisabled() } [Fact] + [Obsolete] public void TestBuilderPathWithLoggerAndCredentials() { var mockLogger = new Mock(); @@ -396,6 +412,7 @@ public void TestBuilderPathWithLoggerAndCredentials() } [Fact] + [Obsolete] public void TestWithLoggerReturnsCorrectInterface() { var mockLogger = new Mock(); @@ -409,6 +426,7 @@ public void TestWithLoggerReturnsCorrectInterface() } [Fact] + [Obsolete] public void TestGenerateDataKeySuccessful() { OrderedDictionary sortedRegionToArnAndClient = @@ -431,6 +449,7 @@ public void TestGenerateDataKeySuccessful() } [Fact] + [Obsolete] public void TestGenerateDataKeyWithKmsFailureShouldThrowKmsException() { OrderedDictionary sortedRegionToArnAndClient = @@ -445,6 +464,7 @@ public void TestGenerateDataKeyWithKmsFailureShouldThrowKmsException() } [Fact] + [Obsolete] public void TestEncryptKeyAndBuildResult() { byte[] encryptedKey = { 0, 1 }; @@ -468,6 +488,7 @@ public void TestEncryptKeyAndBuildResult() } [Fact] + [Obsolete] public void TestEncryptKeyAndBuildResultReturnEmptyOptional() { byte[] dataKeyPlainText = { 0, 1 }; @@ -484,6 +505,7 @@ public void TestEncryptKeyAndBuildResultReturnEmptyOptional() } [Fact] + [Obsolete] public void TestEncryptKeySuccessful() { byte[] encryptedKey = { 3, 4 }; @@ -561,6 +583,7 @@ public void TestEncryptKeySuccessful() } [Fact] + [Obsolete] public async Task TestEncryptKeyAsyncSuccessful() { byte[] encryptedKey = { 3, 4 }; @@ -638,6 +661,7 @@ public async Task TestEncryptKeyAsyncSuccessful() } [Fact] + [Obsolete] public void TestEncryptKeyShouldThrowExceptionAndWipeBytes() { byte[] dataKeyPlainText = { 1, 2 }; @@ -688,6 +712,7 @@ public void TestEncryptKeyShouldThrowExceptionAndWipeBytes() /// for both implementations of the IKeyManagementService classes /// [Fact] + [Obsolete] public void TestEncryptAndDecryptKeyWithStub() { var options = new KeyManagementServiceOptions diff --git a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj index 0004d690a..851e23895 100644 --- a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj +++ b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/csharp/AppEncryption/Crypto/Crypto.csproj b/csharp/AppEncryption/Crypto/Crypto.csproj index a37855925..fc4e7ce34 100644 --- a/csharp/AppEncryption/Crypto/Crypto.csproj +++ b/csharp/AppEncryption/Crypto/Crypto.csproj @@ -21,7 +21,7 @@ - + diff --git a/csharp/AppEncryption/package-updates-report.md b/csharp/AppEncryption/package-updates-report.md new file mode 100644 index 000000000..ff48da844 --- /dev/null +++ b/csharp/AppEncryption/package-updates-report.md @@ -0,0 +1,78 @@ +# NuGet Package Version Check Report + +## Summary +This report shows all NuGet packages across all .csproj files and identifies which ones have newer versions available. + +## Packages with Newer Versions Available + +### AppEncryption/AppEncryption.csproj +- **AWSSDK.DynamoDBv2**: Current `4.0.9.5` → Latest `4.0.9.6` ⚠️ +- **AWSSDK.KeyManagementService**: Current `4.0.7` → Latest `4.0.7.1` ⚠️ + +### AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj +- **AWSSDK.DynamoDBv2**: Current `4.0.7.2` → Latest `4.0.9.6` ⚠️ (major update available) +- **AWSSDK.KeyManagementService**: Current `4.0.4.9` → Latest `4.0.7.1` ⚠️ (major update available) +- **Microsoft.Extensions.Logging.Abstractions**: Current `9.0.9` → Latest `10.0.0` ⚠️ (major update available) + +### AppEncryption.Tests/AppEncryption.Tests.csproj +- **AWSSDK.SecurityToken**: Current `4.0.4` → Latest `4.0.4.1` ⚠️ + +### AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +- **AWSSDK.SecurityToken**: Current `4.0.4` → Latest `4.0.4.1` ⚠️ + +## Packages Up to Date + +### Crypto/Crypto.csproj +- BouncyCastle.NetCore: `2.2.1` ✓ +- GoDaddy.Asherah.SecureMemory: `0.4.0` ✓ +- System.Text.Encodings.Web: `10.0.0` ✓ +- System.Text.Json: `10.0.0` ✓ + +### AppEncryption/AppEncryption.csproj +- LanguageExt.Core: `4.4.9` ✓ +- Microsoft.Extensions.Caching.Memory: `10.0.0` ✓ +- Microsoft.Extensions.Logging.Abstractions: `10.0.0` ✓ +- Newtonsoft.Json: `13.0.4` ✓ +- App.Metrics: `4.3.0` ✓ +- System.Text.Encodings.Web: `10.0.0` ✓ +- System.Text.Json: `10.0.0` ✓ + +### AppEncryption.Tests/AppEncryption.Tests.csproj +- JunitXml.TestLogger: `7.0.2` ✓ +- coverlet.msbuild: `6.0.4` ✓ +- Microsoft.Extensions.Logging.Console: `10.0.0` ✓ +- Microsoft.NET.Test.Sdk: `18.0.1` ✓ +- Moq: `4.20.72` ✓ +- MySql.Data: `9.5.0` ✓ +- Testcontainers.DynamoDb: `4.8.1` ✓ +- Testcontainers.MySql: `4.8.1` ✓ +- xunit: `2.9.3` ✓ +- xunit.runner.visualstudio: `3.1.5` ✓ +- Xunit.SkippableFact: `1.5.23` ✓ + +### AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +- coverlet.msbuild: `6.0.4` ✓ +- MySql.Data: `9.5.0` ✓ +- NetEscapades.Configuration.Yaml: `3.1.0` ✓ +- Microsoft.Extensions.Logging.Console: `10.0.0` ✓ +- Microsoft.NET.Test.Sdk: `18.0.1` ✓ +- Moq: `4.20.72` ✓ +- xunit: `2.9.3` ✓ +- xunit.runner.visualstudio: `3.1.5` ✓ +- Xunit.SkippableFact: `1.5.23` ✓ + +## Recommendations + +1. **High Priority**: Update `AppEncryption.PlugIns.Aws` project packages: + - AWSSDK.DynamoDBv2: `4.0.7.2` → `4.0.9.6` + - AWSSDK.KeyManagementService: `4.0.4.9` → `4.0.7.1` + - Microsoft.Extensions.Logging.Abstractions: `9.0.9` → `10.0.0` + +2. **Medium Priority**: Minor updates: + - AppEncryption: AWSSDK.DynamoDBv2 `4.0.9.5` → `4.0.9.6` + - AppEncryption: AWSSDK.KeyManagementService `4.0.7` → `4.0.7.1` + - Tests: AWSSDK.SecurityToken `4.0.4` → `4.0.4.1` (in both test projects) + +## Notes +- All other packages are at their latest versions +- The AWS SDK packages in the PlugIns.Aws project are significantly behind and should be updated to match the versions in the main AppEncryption project From 80560260b76b9df79cc032e57684ba7fce75685f Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 15 Nov 2025 06:44:16 -0700 Subject: [PATCH 3/8] removed temp report file --- .../AppEncryption/package-updates-report.md | 78 ------------------- 1 file changed, 78 deletions(-) delete mode 100644 csharp/AppEncryption/package-updates-report.md diff --git a/csharp/AppEncryption/package-updates-report.md b/csharp/AppEncryption/package-updates-report.md deleted file mode 100644 index ff48da844..000000000 --- a/csharp/AppEncryption/package-updates-report.md +++ /dev/null @@ -1,78 +0,0 @@ -# NuGet Package Version Check Report - -## Summary -This report shows all NuGet packages across all .csproj files and identifies which ones have newer versions available. - -## Packages with Newer Versions Available - -### AppEncryption/AppEncryption.csproj -- **AWSSDK.DynamoDBv2**: Current `4.0.9.5` → Latest `4.0.9.6` ⚠️ -- **AWSSDK.KeyManagementService**: Current `4.0.7` → Latest `4.0.7.1` ⚠️ - -### AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj -- **AWSSDK.DynamoDBv2**: Current `4.0.7.2` → Latest `4.0.9.6` ⚠️ (major update available) -- **AWSSDK.KeyManagementService**: Current `4.0.4.9` → Latest `4.0.7.1` ⚠️ (major update available) -- **Microsoft.Extensions.Logging.Abstractions**: Current `9.0.9` → Latest `10.0.0` ⚠️ (major update available) - -### AppEncryption.Tests/AppEncryption.Tests.csproj -- **AWSSDK.SecurityToken**: Current `4.0.4` → Latest `4.0.4.1` ⚠️ - -### AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj -- **AWSSDK.SecurityToken**: Current `4.0.4` → Latest `4.0.4.1` ⚠️ - -## Packages Up to Date - -### Crypto/Crypto.csproj -- BouncyCastle.NetCore: `2.2.1` ✓ -- GoDaddy.Asherah.SecureMemory: `0.4.0` ✓ -- System.Text.Encodings.Web: `10.0.0` ✓ -- System.Text.Json: `10.0.0` ✓ - -### AppEncryption/AppEncryption.csproj -- LanguageExt.Core: `4.4.9` ✓ -- Microsoft.Extensions.Caching.Memory: `10.0.0` ✓ -- Microsoft.Extensions.Logging.Abstractions: `10.0.0` ✓ -- Newtonsoft.Json: `13.0.4` ✓ -- App.Metrics: `4.3.0` ✓ -- System.Text.Encodings.Web: `10.0.0` ✓ -- System.Text.Json: `10.0.0` ✓ - -### AppEncryption.Tests/AppEncryption.Tests.csproj -- JunitXml.TestLogger: `7.0.2` ✓ -- coverlet.msbuild: `6.0.4` ✓ -- Microsoft.Extensions.Logging.Console: `10.0.0` ✓ -- Microsoft.NET.Test.Sdk: `18.0.1` ✓ -- Moq: `4.20.72` ✓ -- MySql.Data: `9.5.0` ✓ -- Testcontainers.DynamoDb: `4.8.1` ✓ -- Testcontainers.MySql: `4.8.1` ✓ -- xunit: `2.9.3` ✓ -- xunit.runner.visualstudio: `3.1.5` ✓ -- Xunit.SkippableFact: `1.5.23` ✓ - -### AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj -- coverlet.msbuild: `6.0.4` ✓ -- MySql.Data: `9.5.0` ✓ -- NetEscapades.Configuration.Yaml: `3.1.0` ✓ -- Microsoft.Extensions.Logging.Console: `10.0.0` ✓ -- Microsoft.NET.Test.Sdk: `18.0.1` ✓ -- Moq: `4.20.72` ✓ -- xunit: `2.9.3` ✓ -- xunit.runner.visualstudio: `3.1.5` ✓ -- Xunit.SkippableFact: `1.5.23` ✓ - -## Recommendations - -1. **High Priority**: Update `AppEncryption.PlugIns.Aws` project packages: - - AWSSDK.DynamoDBv2: `4.0.7.2` → `4.0.9.6` - - AWSSDK.KeyManagementService: `4.0.4.9` → `4.0.7.1` - - Microsoft.Extensions.Logging.Abstractions: `9.0.9` → `10.0.0` - -2. **Medium Priority**: Minor updates: - - AppEncryption: AWSSDK.DynamoDBv2 `4.0.9.5` → `4.0.9.6` - - AppEncryption: AWSSDK.KeyManagementService `4.0.7` → `4.0.7.1` - - Tests: AWSSDK.SecurityToken `4.0.4` → `4.0.4.1` (in both test projects) - -## Notes -- All other packages are at their latest versions -- The AWS SDK packages in the PlugIns.Aws project are significantly behind and should be updated to match the versions in the main AppEncryption project From 143c2e440d9c763b02f1c5ea1a0c48bd195cc708 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 15 Nov 2025 06:51:50 -0700 Subject: [PATCH 4/8] Configure analyzer settings: suppress CA1848 in main project, set Minimal analysis mode for test projects --- .../AppEncryption.IntegrationTests.csproj | 3 +-- .../AppEncryption.Tests/AppEncryption.Tests.csproj | 3 ++- csharp/AppEncryption/AppEncryption/AppEncryption.csproj | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index c31d5f7b8..16de2058e 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -5,9 +5,8 @@ GoDaddy.Asherah.AppEncryption.IntegrationTests true true - Recommended + Minimal true - $(NoWarn);CA1873 diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index 8b84ac462..b0e351acb 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -5,8 +5,9 @@ GoDaddy.Asherah.AppEncryption.Tests true true - Recommended + Minimal true + $(NoWarn);CS0618 diff --git a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj index 851e23895..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 From cd427ed00ada7e449040f7c096bb1cf9b5bed2ef Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 15 Nov 2025 06:59:35 -0700 Subject: [PATCH 5/8] Remove 28 Obsolete attributes from test files and add CS0618 suppression to integration tests --- .../AppEncryption.IntegrationTests.csproj | 1 + .../ConfigFixture.cs | 2 -- .../AppEncryptionParameterizedTest.cs | 1 - .../Kms/AwsKeyManagementServiceImplTest.cs | 25 ------------------- 4 files changed, 1 insertion(+), 28 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index 16de2058e..2025d58e5 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -7,6 +7,7 @@ true Minimal true + $(NoWarn);CS0618 diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs index fa56430ac..0c8f8d96e 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs @@ -19,7 +19,6 @@ public class ConfigFixture private static readonly char[] SplitChars = { ',' }; private readonly IConfigurationRoot config; - [Obsolete] public ConfigFixture() { // Load the config file name from environment variables. If not found, default to config.yaml @@ -99,7 +98,6 @@ private IMetastore CreateMetastore() return new InMemoryMetastoreImpl(); } - [Obsolete] private KeyManagementService CreateKeyManagementService() { if (KmsType.Equals(KeyManagementAws, StringComparison.OrdinalIgnoreCase)) diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs index 3d7a80373..8adaea560 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs @@ -173,7 +173,6 @@ private sealed class AppEncryptionParameterizedTestData : IEnumerable private static readonly Random Random = new Random(); private readonly ConfigFixture configFixture; - [Obsolete] public AppEncryptionParameterizedTestData() { configFixture = new ConfigFixture(); diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs index a119ee74f..d8764f6ac 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs @@ -50,10 +50,8 @@ public class AwsKeyManagementServiceImplTest : IClassFixture private readonly Mock cryptoMock; private readonly Mock awsKmsClientFactoryMock; private readonly Mock cryptoKeyMock; - [Obsolete] private readonly Mock awsKeyManagementServiceImplSpy; - [Obsolete] public AwsKeyManagementServiceImplTest() { amazonKeyManagementServiceClientMock = new Mock(); @@ -75,7 +73,6 @@ public AwsKeyManagementServiceImplTest() } [Fact] - [Obsolete] public void TestRegionToArnAndClientDictionaryGeneration() { var mockLogger = new Mock(); @@ -95,7 +92,6 @@ public void TestRegionToArnAndClientDictionaryGeneration() } [Fact] - [Obsolete] public void TestDecryptKeySuccessful() { byte[] encryptedKey = { 0, 1 }; @@ -135,7 +131,6 @@ public void TestDecryptKeySuccessful() } [Fact] - [Obsolete] public async Task TestDecryptKeyAsyncSuccessful() { byte[] encryptedKey = { 0, 1 }; @@ -175,7 +170,6 @@ public async Task TestDecryptKeyAsyncSuccessful() [Fact] - [Obsolete] public void TestDecryptKeyWithMissingRegionInPayloadShouldSkipAndSucceed() { byte[] encryptedKey = { 0, 1 }; @@ -221,7 +215,6 @@ public void TestDecryptKeyWithMissingRegionInPayloadShouldSkipAndSucceed() } [Fact] - [Obsolete] public void TestDecryptKeyWithKmsFailureShouldThrowKmsException() { byte[] encryptedKey = { 0, 1 }; @@ -256,7 +249,6 @@ public void TestDecryptKeyWithKmsFailureShouldThrowKmsException() } [Fact] - [Obsolete] public void TestGetPrioritizedKmsRegionKeyJsonList() { string json = @@ -274,7 +266,6 @@ public void TestGetPrioritizedKmsRegionKeyJsonList() } [Fact] - [Obsolete] public void TestDecryptKmsEncryptedKeySuccessful() { byte[] cipherText = { 0, 1 }; @@ -304,7 +295,6 @@ public void TestDecryptKmsEncryptedKeySuccessful() } [Fact] - [Obsolete] public void TestDecryptKmsEncryptedKeyWithKmsFailureShouldThrowException() { byte[] cipherText = { 0, 1 }; @@ -323,7 +313,6 @@ public void TestDecryptKmsEncryptedKeyWithKmsFailureShouldThrowException() } [Fact] - [Obsolete] public void TestDecryptKmsEncryptedKeyWithCryptoFailureShouldThrowExceptionAndWipeBytes() { byte[] cipherText = { 0, 1 }; @@ -353,7 +342,6 @@ public void TestDecryptKmsEncryptedKeyWithCryptoFailureShouldThrowExceptionAndWi } [Fact] - [Obsolete] public void TestPrimaryBuilderPath() { AwsKeyManagementServiceImpl.Builder awsKeyManagementServicePrimaryBuilder = @@ -363,7 +351,6 @@ public void TestPrimaryBuilderPath() } [Fact] - [Obsolete] public void TestBuilderPathWithCredentials() { AwsKeyManagementServiceImpl.Builder awsKeyManagementServicePrimaryBuilder = @@ -375,7 +362,6 @@ public void TestBuilderPathWithCredentials() } [Fact] - [Obsolete] public void TestBuilderPathWithLoggerEnabled() { var mockLogger = new Mock(); @@ -388,7 +374,6 @@ public void TestBuilderPathWithLoggerEnabled() } [Fact] - [Obsolete] public void TestBuilderPathWithLoggerDisabled() { AwsKeyManagementServiceImpl.Builder awsKeyManagementServicePrimaryBuilder = @@ -398,7 +383,6 @@ public void TestBuilderPathWithLoggerDisabled() } [Fact] - [Obsolete] public void TestBuilderPathWithLoggerAndCredentials() { var mockLogger = new Mock(); @@ -412,7 +396,6 @@ public void TestBuilderPathWithLoggerAndCredentials() } [Fact] - [Obsolete] public void TestWithLoggerReturnsCorrectInterface() { var mockLogger = new Mock(); @@ -426,7 +409,6 @@ public void TestWithLoggerReturnsCorrectInterface() } [Fact] - [Obsolete] public void TestGenerateDataKeySuccessful() { OrderedDictionary sortedRegionToArnAndClient = @@ -449,7 +431,6 @@ public void TestGenerateDataKeySuccessful() } [Fact] - [Obsolete] public void TestGenerateDataKeyWithKmsFailureShouldThrowKmsException() { OrderedDictionary sortedRegionToArnAndClient = @@ -464,7 +445,6 @@ public void TestGenerateDataKeyWithKmsFailureShouldThrowKmsException() } [Fact] - [Obsolete] public void TestEncryptKeyAndBuildResult() { byte[] encryptedKey = { 0, 1 }; @@ -488,7 +468,6 @@ public void TestEncryptKeyAndBuildResult() } [Fact] - [Obsolete] public void TestEncryptKeyAndBuildResultReturnEmptyOptional() { byte[] dataKeyPlainText = { 0, 1 }; @@ -505,7 +484,6 @@ public void TestEncryptKeyAndBuildResultReturnEmptyOptional() } [Fact] - [Obsolete] public void TestEncryptKeySuccessful() { byte[] encryptedKey = { 3, 4 }; @@ -583,7 +561,6 @@ public void TestEncryptKeySuccessful() } [Fact] - [Obsolete] public async Task TestEncryptKeyAsyncSuccessful() { byte[] encryptedKey = { 3, 4 }; @@ -661,7 +638,6 @@ public async Task TestEncryptKeyAsyncSuccessful() } [Fact] - [Obsolete] public void TestEncryptKeyShouldThrowExceptionAndWipeBytes() { byte[] dataKeyPlainText = { 1, 2 }; @@ -712,7 +688,6 @@ public void TestEncryptKeyShouldThrowExceptionAndWipeBytes() /// for both implementations of the IKeyManagementService classes /// [Fact] - [Obsolete] public void TestEncryptAndDecryptKeyWithStub() { var options = new KeyManagementServiceOptions From b7dbe46d539499ba22f986f9f386e53c59174229 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Tue, 2 Dec 2025 06:59:43 -0700 Subject: [PATCH 6/8] moved setup to each test, and other PR feedback --- .../Kms/KeyManagementServiceTestBuilder.cs | 119 ++++++++ .../Aws/Kms/KeyManagementServiceTests.cs | 256 +++++++----------- 2 files changed, 215 insertions(+), 160 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTestBuilder.cs 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 index 31ef3f2b9..8367302e3 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs @@ -1,18 +1,19 @@ 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.PlugIns.Aws.Kms; 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 : IDisposable + public class KeyManagementServiceTests { private const string UsEast1 = "us-east-1"; private const string ArnUsEast1 = "arn-us-east-1"; @@ -21,159 +22,43 @@ public class KeyManagementServiceTests : IDisposable private const string EuWest2 = "eu-west-2"; private const string ArnEuWest2 = "arn-eu-west-2"; - private readonly KeyManagementService _keyManagementServiceEast; - private readonly KeyManagementService _keyManagementServiceWest; - private readonly KeyManagementService _keyManagementServiceAdditionalRegion; - private readonly KeyManagementService _keyManagementServiceSomeBroken; - private readonly KeyManagementService _keyManagementServiceAllBroken; - private readonly KeyManagementService _keyManagementServiceOnlyEurope; - - public KeyManagementServiceTests() - { - var optionsEast = new KeyManagementServiceOptions - { - RegionKeyArns = - [ - new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 }, - new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 } - ] - }; - - var optionsWest = new KeyManagementServiceOptions - { - RegionKeyArns = - [ - new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 }, - new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 } - ] - }; - - var optionsEurope = new KeyManagementServiceOptions - { - RegionKeyArns = - [ - new RegionKeyArn { Region = EuWest2, KeyArn = ArnEuWest2 }, - new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 }, - new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 } - ] - }; - - var optionsOnlyEurope = new KeyManagementServiceOptions - { - RegionKeyArns = - [ - new RegionKeyArn { Region = EuWest2, KeyArn = ArnEuWest2 } - ] - }; - - var optionsSomeBroken = new KeyManagementServiceOptions - { - RegionKeyArns = - [ - new RegionKeyArn { Region = UsEast1, KeyArn = "ERROR" }, - new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 } - ] - }; - - var optionsAllBroken = new KeyManagementServiceOptions - { - RegionKeyArns = - [ - new RegionKeyArn { Region = UsEast1, KeyArn = "ERROR" }, - new RegionKeyArn { Region = UsWest2, KeyArn = "ERROR" } - ] - }; - - var loggerFactoryStub = new LoggerFactoryStub(); - var clientFactoryStub = new KeyManagementClientFactoryStub(optionsEast); - - _keyManagementServiceEast = KeyManagementService.NewBuilder() - .WithLoggerFactory(loggerFactoryStub) - .WithOptions(optionsEast) - .WithKmsClientFactory(clientFactoryStub) - .Build(); - - _keyManagementServiceWest = KeyManagementService.NewBuilder() - .WithLoggerFactory(loggerFactoryStub) - .WithOptions(optionsWest) - .WithKmsClientFactory(clientFactoryStub) - .Build(); - - var clientFactoryStubSomeBroken = new KeyManagementClientFactoryStub(optionsSomeBroken); - _keyManagementServiceSomeBroken = KeyManagementService.NewBuilder() - .WithLoggerFactory(loggerFactoryStub) - .WithOptions(optionsSomeBroken) - .WithKmsClientFactory(clientFactoryStubSomeBroken) - .Build(); - - var clientFactoryStubAllBroken = new KeyManagementClientFactoryStub(optionsAllBroken); - _keyManagementServiceAllBroken = KeyManagementService.NewBuilder() - .WithLoggerFactory(loggerFactoryStub) - .WithOptions(optionsAllBroken) - .WithKmsClientFactory(clientFactoryStubAllBroken) - .Build(); - - var clientFactoryStubEurope = new KeyManagementClientFactoryStub(optionsEurope); - _keyManagementServiceAdditionalRegion = KeyManagementService.NewBuilder() - .WithLoggerFactory(loggerFactoryStub) - .WithOptions(optionsEurope) - .WithKmsClientFactory(clientFactoryStubEurope) - .Build(); - - var clientFactoryStubOnlyEurope = new KeyManagementClientFactoryStub(optionsOnlyEurope); - _keyManagementServiceOnlyEurope = KeyManagementService.NewBuilder() - .WithLoggerFactory(loggerFactoryStub) - .WithOptions(optionsOnlyEurope) - .WithKmsClientFactory(clientFactoryStubOnlyEurope) - .Build(); - } - [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 _keyManagementServiceEast.EncryptKeyAsync(key); - - // Assert - ValidateEncryptedKey(result); - } - - [Fact] - public async Task EncryptKeyAsync_RegionFallback_Succeeds() - { - // Arrange - using var crypto = new BouncyAes256GcmCrypto(); - var keyCreationTime = DateTimeOffset.UtcNow.Truncate(TimeSpan.FromMinutes(1)); - using var key = crypto.GenerateKey(keyCreationTime); - - // Act - var result = await _keyManagementServiceEast.EncryptKeyAsync(key); + var result = await service.EncryptKeyAsync(key); // Assert - ValidateEncryptedKey(result); + 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 = _keyManagementServiceEast.EncryptKey(key); + var result = service.EncryptKey(key); // Assert - ValidateEncryptedKey(result); + ValidateEncryptedKey(result, UsEast1, UsWest2); } - private static void ValidateEncryptedKey(byte[] encryptedKeyResult) + private static void ValidateEncryptedKey(byte[] encryptedKeyResult, params string[] expectedRegions) { Assert.NotNull(encryptedKeyResult); @@ -202,9 +87,10 @@ private static void ValidateEncryptedKey(byte[] encryptedKeyResult) Assert.True(kmsKeks is System.Text.Json.Nodes.JsonArray); var kmsKeksArray = kmsKeks!.AsArray(); - Assert.Equal(2, kmsKeksArray.Count); // Should have 2 regions + 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); @@ -218,7 +104,7 @@ private static void ValidateEncryptedKey(byte[] encryptedKeyResult) Assert.NotNull(region); var regionValue = region!.AsValue().GetValue(); Assert.NotNull(regionValue); - Assert.True(regionValue == "us-east-1" || regionValue == "us-west-2"); + actualRegions.Add(regionValue); // Assert arn exists Assert.True(kekObject.ContainsKey("arn")); @@ -226,7 +112,6 @@ private static void ValidateEncryptedKey(byte[] encryptedKeyResult) Assert.NotNull(arn); var arnValue = arn!.AsValue().GetValue(); Assert.NotNull(arnValue); - Assert.True(arnValue == "arn-us-east-1" || arnValue == "arn-us-west-2" || arnValue == "ERROR"); // Assert encryptedKek exists and is not empty Assert.True(kekObject.ContainsKey("encryptedKek")); @@ -237,10 +122,11 @@ private static void ValidateEncryptedKey(byte[] encryptedKeyResult) Assert.NotEmpty(encryptedKekValue); } - // Assert we have both regions - var regions = kmsKeksArray.Select(kek => kek!.AsObject()["region"]!.AsValue().GetValue()).ToList(); - Assert.Contains("us-east-1", regions); - Assert.Contains("us-west-2", regions); + // Assert we have all expected regions + foreach (var expectedRegion in expectedRegions) + { + Assert.Contains(expectedRegion, actualRegions); + } } @@ -248,13 +134,16 @@ private static void ValidateEncryptedKey(byte[] encryptedKeyResult) 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 _keyManagementServiceEast.EncryptKeyAsync(originalKey); - var decryptedKey = await _keyManagementServiceEast.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false); + var encryptedResult = await service.EncryptKeyAsync(originalKey); + var decryptedKey = await service.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false); // Assert Assert.NotNull(decryptedKey); @@ -266,13 +155,16 @@ public async Task DecryptKeyAsync_ShouldDecryptKey() 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 = _keyManagementServiceEast.EncryptKey(originalKey); - var decryptedKey = _keyManagementServiceEast.DecryptKey(encryptedResult, keyCreationTime, revoked: false); + var encryptedResult = service.EncryptKey(originalKey); + var decryptedKey = service.DecryptKey(encryptedResult, keyCreationTime, revoked: false); // Assert Assert.NotNull(decryptedKey); @@ -284,13 +176,19 @@ public void DecryptKey_ShouldDecryptKey() 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 _keyManagementServiceEast.EncryptKeyAsync(originalKey); - var decryptedKey = await _keyManagementServiceWest.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false); + var encryptedResult = await serviceEast.EncryptKeyAsync(originalKey); + var decryptedKey = await serviceWest.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false); // Assert Assert.NotNull(decryptedKey); @@ -302,13 +200,19 @@ public async Task DecryptKeyAsync_ShouldDecryptKey_BetweenRegions() 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 West - var encryptedResult = await _keyManagementServiceEast.EncryptKeyAsync(originalKey); - var decryptedKey = await _keyManagementServiceAdditionalRegion.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false); + // 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); @@ -320,13 +224,19 @@ public async Task DecryptKeyAsync_ShouldDecryptKey_BetweenRegions_WhenNewRegionE 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 West - var encryptedResult = await _keyManagementServiceEast.EncryptKeyAsync(originalKey); - var decryptTask = _keyManagementServiceOnlyEurope.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false); + // 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); @@ -336,13 +246,19 @@ public async Task DecryptKeyAsync_ShouldFail_BetweenRegions_WhenNoRegionsMatch() 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 = _keyManagementServiceEast.EncryptKey(originalKey); - var decryptedKey = _keyManagementServiceWest.DecryptKey(encryptedResult, keyCreationTime, revoked: false); + var encryptedResult = serviceEast.EncryptKey(originalKey); + var decryptedKey = serviceWest.DecryptKey(encryptedResult, keyCreationTime, revoked: false); // Assert Assert.NotNull(decryptedKey); @@ -354,41 +270,61 @@ public void DecryptKey_ShouldDecryptKey_BetweenRegions() 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 _keyManagementServiceAllBroken.EncryptKeyAsync(key)); + 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 _keyManagementServiceSomeBroken.EncryptKeyAsync(key); + var result = await service.EncryptKeyAsync(key); // Assert - ValidateEncryptedKey(result); + 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() { - await Assert.ThrowsAsync(async () => - await _keyManagementServiceEast.EncryptKeyAsync(null!)); - } + // Arrange + using var service = KeyManagementServiceTestBuilder.Create() + .WithRegions((UsEast1, ArnUsEast1), (UsWest2, ArnUsWest2)) + .Build(); - public void Dispose() - { - _keyManagementServiceEast?.Dispose(); - _keyManagementServiceWest?.Dispose(); + // Act & Assert + await Assert.ThrowsAsync(async () => + await service.EncryptKeyAsync(null!)); } } } From bfb4287a0e1a78824b5b24acf6743770163c42e6 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Tue, 2 Dec 2025 07:04:19 -0700 Subject: [PATCH 7/8] Suppress CA1816 and CA1873 code analysis warnings in test projects --- .../AppEncryption.IntegrationTests.csproj | 4 ++-- .../AppEncryption.Tests/AppEncryption.Tests.csproj | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index 2025d58e5..6881d71c4 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -5,9 +5,9 @@ GoDaddy.Asherah.AppEncryption.IntegrationTests true true - Minimal + Minimum true - $(NoWarn);CS0618 + $(NoWarn);CS0618;CA1816;CA1873 diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index b0e351acb..e452a8565 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -5,9 +5,9 @@ GoDaddy.Asherah.AppEncryption.Tests true true - Minimal + Minimum true - $(NoWarn);CS0618 + $(NoWarn);CS0618;CA1816 From a8d19e364619e158036bfb028ffe87b378947eb0 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Tue, 2 Dec 2025 08:50:00 -0700 Subject: [PATCH 8/8] Add OptimizeByRegions extension method and upgrade guide --- .../KeyManagementServiceOptionsExtensions.cs | 54 +++++ .../Kms/KeyManagementServiceOptionsTests.cs | 225 ++++++++++++++++++ csharp/AppEncryption/README.md | 30 ++- .../docs/plugins-upgrade-guide.md | 166 +++++++++++++ 4 files changed, 466 insertions(+), 9 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptionsExtensions.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceOptionsTests.cs create mode 100644 csharp/AppEncryption/docs/plugins-upgrade-guide.md 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.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/README.md b/csharp/AppEncryption/README.md index fb4797832..7a366c4e8 100644 --- a/csharp/AppEncryption/README.md +++ b/csharp/AppEncryption/README.md @@ -171,14 +171,15 @@ Detailed information about the Key Management Service can be found [here](../../ > [!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 +> 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# var keyManagementService = KeyManagementService.NewBuilder() .WithLoggerFactory(myLoggerFactory) // required - .WithRegionKeyArn("us-east-1", "arn:aws:kms:us-east-1:123456789012:key/abc") // add these in preferred order + .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() @@ -216,7 +217,6 @@ Then, in code: ```c# // In your DI container setup (e.g., Startup.cs, Program.cs) // assumes you also have setup ILoggerFactory -// In your DI container setup (e.g., Startup.cs, Program.cs) services.AddDefaultAWSOptions(Configuration.GetAWSOptions()); var kmsOptions = Configuration.GetValue("AsherahKmsOptions"); @@ -225,13 +225,25 @@ services.AddSingleton(kmsOptions); // Then later in a class public class MyService(AWSOptions awsOptions, KeyManagementServiceOptions kmsOptions, ILoggerFactory loggerFactory) { - var keyManagementService = KeyManagementService.NewBuilder() - .WithLoggerFactory(loggerFactory) - .WithOptions(kmsOptions) - .WithCredentials(awsOptions.GetCredentials()) - .Build(); + 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; + } - // pass keyManagementService to the SessionFactory builder } ``` 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