From 2e12a9ea8c4ebb9f566a49cb59c517bf8261f348 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sun, 31 Aug 2025 11:28:01 -0700 Subject: [PATCH 01/31] breaking interface on Crypto EnvelopeEncrypt --- .../EnvelopeEncryptionJsonImplTest.cs | 8 +- .../Crypto/Envelope/AeadEnvelopeCryptoTest.cs | 10 +-- .../Envelope/EnvelopeEncryptionJsonImpl.cs | 15 ++-- .../AppEncryption/GlobalSuppressions.cs | 1 + .../AppEncryption/Models/KeyMeta.cs | 32 ++++++++ .../AppEncryption/Models/KeyRecord.cs | 79 ++++++++++++++++++ .../DynamoDbKeyMetastoreAdapter.cs | 82 +++++++++++++++++++ .../Persistence/IKeyMetastore.cs | 51 ++++++++++++ .../Crypto/Envelope/AeadEnvelopeCrypto.cs | 12 +-- .../Crypto/Envelope/EnvelopeEncryptResult.cs | 5 +- 10 files changed, 268 insertions(+), 27 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs create mode 100644 csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs create mode 100644 csharp/AppEncryption/AppEncryption/Persistence/DynamoDbKeyMetastoreAdapter.cs create mode 100644 csharp/AppEncryption/AppEncryption/Persistence/IKeyMetastore.cs diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionJsonImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionJsonImplTest.cs index f937ffeff..4857e1f96 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionJsonImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionJsonImplTest.cs @@ -138,22 +138,22 @@ private void TestEncryptPayload() intermediateCryptoKeyMock.Setup(x => x.GetCreated()).Returns(ikDateTime); envelopeEncryptionJsonImplSpy - .Setup(x => x.WithIntermediateKeyForWrite(It.IsAny>())) - .Returns>(functionWithIntermediateKey => + .Setup(x => x.WithIntermediateKeyForWrite(It.IsAny>>())) + .Returns>>(functionWithIntermediateKey => functionWithIntermediateKey(intermediateCryptoKeyMock.Object)); byte[] decryptedPayload = Encoding.Unicode.GetBytes("somepayload"); KeyMeta intermediateKeyMeta = new KeyMeta(partition.IntermediateKeyId, ikDateTime); byte[] encryptedPayload = { 0, 1, 2, 3 }; byte[] encryptedKey = { 4, 5, 6, 7 }; - EnvelopeEncryptResult envelopeEncryptResult = new EnvelopeEncryptResult + EnvelopeEncryptResult envelopeEncryptResult = new EnvelopeEncryptResult { CipherText = encryptedPayload, EncryptedKey = encryptedKey, UserState = intermediateKeyMeta, }; aeadEnvelopeCryptoMock - .Setup(x => x.EnvelopeEncrypt(decryptedPayload, intermediateCryptoKeyMock.Object, intermediateKeyMeta)) + .Setup(x => x.EnvelopeEncrypt(decryptedPayload, intermediateCryptoKeyMock.Object, intermediateKeyMeta)) .Returns(envelopeEncryptResult); EnvelopeKeyRecord expectedDataRowKey = new EnvelopeKeyRecord(drkDateTime, intermediateKeyMeta, encryptedKey); diff --git a/csharp/AppEncryption/AppEncryption.Tests/Crypto/Envelope/AeadEnvelopeCryptoTest.cs b/csharp/AppEncryption/AppEncryption.Tests/Crypto/Envelope/AeadEnvelopeCryptoTest.cs index 54e5d05b8..4d5903d94 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/Crypto/Envelope/AeadEnvelopeCryptoTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/Crypto/Envelope/AeadEnvelopeCryptoTest.cs @@ -85,10 +85,10 @@ private void TestEnvelopeEncrypt() aeadEnvelopeCryptoMock.Setup(x => x.EncryptKey(keyMock.Object, keyEncryptionKey.Object)) .Returns(expectedEncryptedKey); aeadEnvelopeCryptoMock.Setup(x => x.GenerateKey()).Returns(keyMock.Object); - aeadEnvelopeCryptoMock.Setup(x => x.EnvelopeEncrypt(expectedPlainText, keyEncryptionKey.Object, null)) + aeadEnvelopeCryptoMock.Setup(x => x.EnvelopeEncrypt(expectedPlainText, keyEncryptionKey.Object, null)) .CallBase(); - EnvelopeEncryptResult result = aeadEnvelopeCryptoMock.Object.EnvelopeEncrypt(expectedPlainText, keyEncryptionKey.Object, null); + EnvelopeEncryptResult result = aeadEnvelopeCryptoMock.Object.EnvelopeEncrypt(expectedPlainText, keyEncryptionKey.Object, null); Assert.Equal(expectedCipherText, result.CipherText); Assert.Equal(expectedEncryptedKey, result.EncryptedKey); Assert.Null(result.UserState); @@ -99,11 +99,11 @@ private void TestEnvelopeEncrypt() private void TestEnvelopeEncryptWithTwoParams() { byte[] encryptedKey = { 0, 1, 2, 3, 4, 5, 6, 7, 8 }; - aeadEnvelopeCryptoMock.Setup(x => x.EnvelopeEncrypt( + aeadEnvelopeCryptoMock.Setup(x => x.EnvelopeEncrypt( encryptedKey, keyEncryptionKey.Object)).CallBase(); - aeadEnvelopeCryptoMock.Object.EnvelopeEncrypt(encryptedKey, keyEncryptionKey.Object); - aeadEnvelopeCryptoMock.Verify(x => x.EnvelopeEncrypt(encryptedKey, keyEncryptionKey.Object, null)); + aeadEnvelopeCryptoMock.Object.EnvelopeEncrypt(encryptedKey, keyEncryptionKey.Object); + aeadEnvelopeCryptoMock.Verify(x => x.EnvelopeEncrypt(encryptedKey, keyEncryptionKey.Object, null)); } [Fact] diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs index 43ee802d2..7070a354f 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs @@ -151,17 +151,14 @@ public virtual JObject EncryptPayload(byte[] payload) { using (MetricsUtil.MetricsInstance.Measure.Timer.Time(EncryptTimerOptions)) { - EnvelopeEncryptResult result = WithIntermediateKeyForWrite(intermediateCryptoKey => crypto.EnvelopeEncrypt( - payload, - intermediateCryptoKey, - new KeyMeta(partition.IntermediateKeyId, intermediateCryptoKey.GetCreated()))); + var result = WithIntermediateKeyForWrite(intermediateCryptoKey => crypto.EnvelopeEncrypt( + payload, + intermediateCryptoKey, + new KeyMeta(partition.IntermediateKeyId, intermediateCryptoKey.GetCreated()))); - KeyMeta parentKeyMeta = (KeyMeta)result.UserState; + var keyRecord = new EnvelopeKeyRecord(DateTimeOffset.UtcNow, result.UserState, result.EncryptedKey); - EnvelopeKeyRecord keyRecord = - new EnvelopeKeyRecord(DateTimeOffset.UtcNow, parentKeyMeta, result.EncryptedKey); - - Json wrapperDocument = new Json(); + var wrapperDocument = new Json(); wrapperDocument.Put("Key", keyRecord.ToJson()); wrapperDocument.Put("Data", result.CipherText); diff --git a/csharp/AppEncryption/AppEncryption/GlobalSuppressions.cs b/csharp/AppEncryption/AppEncryption/GlobalSuppressions.cs index 624ec23b3..85f18dcc3 100644 --- a/csharp/AppEncryption/AppEncryption/GlobalSuppressions.cs +++ b/csharp/AppEncryption/AppEncryption/GlobalSuppressions.cs @@ -5,3 +5,4 @@ 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/Models/KeyMeta.cs b/csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs new file mode 100644 index 000000000..92953bbcb --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs @@ -0,0 +1,32 @@ +using System; + +namespace GoDaddy.Asherah.AppEncryption.Models +{ + /// + /// Represents metadata for a parent key. + /// + public class KeyMeta + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The key identifier. + /// The creation time of the key. + public KeyMeta(string id, DateTimeOffset created) + { + Id = id ?? throw new ArgumentNullException(nameof(id)); + Created = created; + } + + /// + /// Gets the key identifier. + /// + public string Id { get; } + + /// + /// Gets the creation time of the key. + /// + public DateTimeOffset Created { get; } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs b/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs new file mode 100644 index 000000000..14be07fd3 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs @@ -0,0 +1,79 @@ +using System; + +namespace GoDaddy.Asherah.AppEncryption.Models +{ + /// + /// Represents a key record with basic properties for encrypted keys. + /// System KeyRecords will not have a ParentKeyMeta, while Intermediate KeyRecords will have a ParentKeyMeta. + /// + public class KeyRecord + { + /// + /// Initializes a new instance of the class. + /// + /// + /// Creation time of the encrypted key. + /// The encrypted key bytes. + /// The revocation status of the encrypted key. + /// The metadata for the parent key, if any. + private KeyRecord(DateTimeOffset created, byte[] encryptedKey, bool revoked, KeyMeta parentKeyMeta = null) + { + Created = created; + EncryptedKey = encryptedKey ?? throw new ArgumentNullException(nameof(encryptedKey)); + Revoked = revoked; + ParentKeyMeta = parentKeyMeta; + } + + /// + /// Gets the creation time of the encrypted key. + /// + public DateTimeOffset Created { get; } + + /// + /// Gets the encrypted key bytes. + /// + public byte[] EncryptedKey { get; } + + /// + /// Gets the revocation status of the encrypted key. + /// + public bool Revoked { get; } + + /// + /// Gets the metadata for the parent key, if any. + /// + public KeyMeta ParentKeyMeta { get; } + + /// + /// Creates a system key record with no parent key metadata. + /// + /// + /// Creation time of the encrypted key. + /// The encrypted key bytes. + /// The revocation status of the encrypted key. + /// A new system key record. + public static KeyRecord NewSystemKeyRecord(DateTimeOffset created, byte[] encryptedKey, bool revoked) + { + return new KeyRecord(created, encryptedKey, revoked, null); + } + + /// + /// Creates an intermediate key record with parent key metadata. + /// + /// + /// Creation time of the encrypted key. + /// The encrypted key bytes. + /// The revocation status of the encrypted key. + /// The metadata for the parent key. + /// A new intermediate key record. + public static KeyRecord NewIntermediateKeyRecord(DateTimeOffset created, byte[] encryptedKey, bool revoked, KeyMeta parentKeyMeta) + { + if (parentKeyMeta == null) + { + throw new ArgumentNullException(nameof(parentKeyMeta)); + } + + return new KeyRecord(created, encryptedKey, revoked, parentKeyMeta); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Persistence/DynamoDbKeyMetastoreAdapter.cs b/csharp/AppEncryption/AppEncryption/Persistence/DynamoDbKeyMetastoreAdapter.cs new file mode 100644 index 000000000..82b87e1ac --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Persistence/DynamoDbKeyMetastoreAdapter.cs @@ -0,0 +1,82 @@ +using System; +using GoDaddy.Asherah.AppEncryption.Models; +using LanguageExt; +using Newtonsoft.Json.Linq; + +namespace GoDaddy.Asherah.AppEncryption.Persistence +{ + /// + /// Adapter that wraps DynamoDbMetastoreImpl to work with the new IKeyMetastore interface. + /// Converts between KeyRecord and JObject for storage. + /// + public class DynamoDbKeyMetastoreAdapter : IKeyMetastore + { + private readonly DynamoDbMetastoreImpl _dynamoDbMetastore; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The DynamoDB metastore implementation to wrap. + public DynamoDbKeyMetastoreAdapter(DynamoDbMetastoreImpl dynamoDbMetastore) + { + _dynamoDbMetastore = dynamoDbMetastore ?? throw new ArgumentNullException(nameof(dynamoDbMetastore)); + } + + /// + public bool TryLoad(string keyId, DateTimeOffset created, out KeyRecord keyRecord) + { + var option = _dynamoDbMetastore.Load(keyId, created); + if (option.IsSome) + { + keyRecord = ConvertFromJObject((JObject)option); + return true; + } + + keyRecord = null; + return false; + } + + /// + public bool TryLoadLatest(string keyId, out KeyRecord keyRecord) + { + var option = _dynamoDbMetastore.LoadLatest(keyId); + if (option.IsSome) + { + keyRecord = ConvertFromJObject((JObject)option); + return true; + } + + keyRecord = null; + return false; + } + + /// + public bool Store(string keyId, DateTimeOffset created, KeyRecord keyRecord) + { + var jObject = ConvertToJObject(keyRecord); + return _dynamoDbMetastore.Store(keyId, created, jObject); + } + + /// + public string GetKeySuffix() + { + return _dynamoDbMetastore.GetKeySuffix(); + } + + private static KeyRecord ConvertFromJObject(JObject jObject) + { + // Convert JObject back to KeyRecord + // This would need to handle the JSON structure of the existing EnvelopeKeyRecord format + // and convert it to our new KeyRecord model + throw new NotImplementedException("Conversion from JObject to KeyRecord not yet implemented"); + } + + private static JObject ConvertToJObject(KeyRecord keyRecord) + { + // Convert KeyRecord to JObject + // This would need to create the JSON structure expected by the existing DynamoDbMetastoreImpl + throw new NotImplementedException("Conversion from KeyRecord to JObject not yet implemented"); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Persistence/IKeyMetastore.cs b/csharp/AppEncryption/AppEncryption/Persistence/IKeyMetastore.cs new file mode 100644 index 000000000..b9727d265 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Persistence/IKeyMetastore.cs @@ -0,0 +1,51 @@ +using System; +using GoDaddy.Asherah.AppEncryption.Models; + +namespace GoDaddy.Asherah.AppEncryption.Persistence +{ + /// + /// The KeyMetastore interface provides methods that can be used to load and store system and intermediate keys from a + /// supported database using key records. + /// + public interface IKeyMetastore + { + /// + /// Attempts to load the key record associated with the keyId and created time. + /// + /// + /// The keyId to lookup. + /// The created time to lookup. + /// The key record if found. + /// True if the key record was found, false otherwise. + bool TryLoad(string keyId, DateTimeOffset created, out KeyRecord keyRecord); + + /// + /// Attempts to load the latest key record associated with the keyId. + /// + /// + /// The keyId to lookup. + /// The latest key record if found. + /// True if a key record was found, false otherwise. + bool TryLoadLatest(string keyId, out KeyRecord keyRecord); + + /// + /// Stores the key record using the specified keyId and created time. + /// + /// + /// The keyId to store. + /// The created time to store. + /// The key record to store. + /// True if the store succeeded, false if the store failed for a known condition e.g., trying to save + /// a duplicate value should return false, not throw an exception. + bool Store(string keyId, DateTimeOffset created, KeyRecord keyRecord); + + /// + /// Returns the key suffix or "" if key suffix option is disabled. + /// + /// + /// + /// The key suffix. + /// + string GetKeySuffix(); + } +} diff --git a/csharp/AppEncryption/Crypto/Envelope/AeadEnvelopeCrypto.cs b/csharp/AppEncryption/Crypto/Envelope/AeadEnvelopeCrypto.cs index 416592114..49b3ca210 100644 --- a/csharp/AppEncryption/Crypto/Envelope/AeadEnvelopeCrypto.cs +++ b/csharp/AppEncryption/Crypto/Envelope/AeadEnvelopeCrypto.cs @@ -63,10 +63,10 @@ public virtual CryptoKey DecryptKey( /// /// The payload to be encrypted. /// The key encryption key. - /// A object/data row record (DRR). - public virtual EnvelopeEncryptResult EnvelopeEncrypt(byte[] plainText, CryptoKey keyEncryptionKey) + /// A object/data row record (DRR). + public virtual EnvelopeEncryptResult EnvelopeEncrypt(byte[] plainText, CryptoKey keyEncryptionKey) where T : class { - return EnvelopeEncrypt(plainText, keyEncryptionKey, null); + return EnvelopeEncrypt(plainText, keyEncryptionKey, null); } /// @@ -76,12 +76,12 @@ public virtual EnvelopeEncryptResult EnvelopeEncrypt(byte[] plainText, CryptoKey /// The payload to be encrypted. /// The key encryption key. /// The KeyMeta for the . - /// A object/data row record (DRR). - public virtual EnvelopeEncryptResult EnvelopeEncrypt(byte[] plainText, CryptoKey keyEncryptionKey, object userState) + /// A object/data row record (DRR). + public virtual EnvelopeEncryptResult EnvelopeEncrypt(byte[] plainText, CryptoKey keyEncryptionKey, T userState) where T : class { using (CryptoKey dataEncryptionKey = GenerateKey()) { - EnvelopeEncryptResult result = new EnvelopeEncryptResult + EnvelopeEncryptResult result = new EnvelopeEncryptResult { CipherText = Encrypt(plainText, dataEncryptionKey), EncryptedKey = EncryptKey(dataEncryptionKey, keyEncryptionKey), diff --git a/csharp/AppEncryption/Crypto/Envelope/EnvelopeEncryptResult.cs b/csharp/AppEncryption/Crypto/Envelope/EnvelopeEncryptResult.cs index 55ba2e043..9aaf2a190 100644 --- a/csharp/AppEncryption/Crypto/Envelope/EnvelopeEncryptResult.cs +++ b/csharp/AppEncryption/Crypto/Envelope/EnvelopeEncryptResult.cs @@ -1,12 +1,11 @@ namespace GoDaddy.Asherah.Crypto.Envelope { - public class EnvelopeEncryptResult + public class EnvelopeEncryptResult where T : class { public byte[] CipherText { get; set; } public byte[] EncryptedKey { get; set; } - // TODO Consider refactoring this somehow. Ends up always being KeyMeta - public object UserState { get; set; } + public T UserState { get; set; } } } From 4bd9ca8a1c344553c06d45c54bddc3740d089072 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Mon, 1 Sep 2025 09:05:41 -0700 Subject: [PATCH 02/31] in-progress --- .../Metastore/InMemoryKeyMetastoreImplTest.cs | 132 ++++++++++++++++++ .../IKeyMetastore.cs | 2 +- .../Metastore/InMemoryKeyMetastoreImpl.cs | 126 +++++++++++++++++ .../AppEncryption/Models/KeyMeta.cs | 34 +++++ .../AppEncryption/Models/KeyRecord.cs | 8 +- .../DynamoDbKeyMetastoreAdapter.cs | 82 ----------- 6 files changed, 297 insertions(+), 87 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs rename csharp/AppEncryption/AppEncryption/{Persistence => Metastore}/IKeyMetastore.cs (97%) create mode 100644 csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs delete mode 100644 csharp/AppEncryption/AppEncryption/Persistence/DynamoDbKeyMetastoreAdapter.cs diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs new file mode 100644 index 000000000..4c16d312d --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs @@ -0,0 +1,132 @@ +using System; +using GoDaddy.Asherah.AppEncryption.Models; +using GoDaddy.Asherah.AppEncryption.Metastore; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Metastore +{ + public class InMemoryKeyMetastoreImplTest : IDisposable + { + private readonly InMemoryKeyMetastoreImpl inMemoryKeyMetastoreImpl; + + public InMemoryKeyMetastoreImplTest() + { + inMemoryKeyMetastoreImpl = new InMemoryKeyMetastoreImpl(); + } + + [Fact] + private void TestTryLoadAndStoreWithValidKey() + { + const string keyId = "ThisIsMyKey"; + DateTimeOffset created = DateTimeOffset.UtcNow; + var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + + inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + + bool success = inMemoryKeyMetastoreImpl.TryLoad(keyId, created, out KeyRecord actualKeyRecord); + + Assert.True(success); + Assert.Equal(keyRecord, actualKeyRecord); + } + + [Fact] + private void TestTryLoadAndStoreWithInvalidKey() + { + const string keyId = "ThisIsMyKey"; + DateTimeOffset created = DateTimeOffset.UtcNow; + var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + + inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + + bool success = inMemoryKeyMetastoreImpl.TryLoad("some non-existent key", created, out KeyRecord actualKeyRecord); + + Assert.False(success); + Assert.Null(actualKeyRecord); + } + + [Fact] + private void TestTryLoadLatestMultipleCreatedAndValuesForKeyIdShouldReturnLatest() + { + const string keyId = "ThisIsMyKey"; + DateTimeOffset created = DateTimeOffset.UtcNow; + var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + + inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + + DateTimeOffset createdOneHourLater = created.AddHours(1); + var keyRecordOneHourLater = KeyRecord.NewSystemKeyRecord(createdOneHourLater, new byte[] { 4, 5, 6 }, false); + inMemoryKeyMetastoreImpl.Store(keyId, createdOneHourLater, keyRecordOneHourLater); + + DateTimeOffset createdOneDayLater = created.AddDays(1); + var keyRecordOneDayLater = KeyRecord.NewSystemKeyRecord(createdOneDayLater, new byte[] { 7, 8, 9 }, false); + inMemoryKeyMetastoreImpl.Store(keyId, createdOneDayLater, keyRecordOneDayLater); + + DateTimeOffset createdOneWeekEarlier = created.AddDays(-7); + var keyRecordOneWeekEarlier = KeyRecord.NewSystemKeyRecord(createdOneWeekEarlier, new byte[] { 10, 11, 12 }, false); + inMemoryKeyMetastoreImpl.Store(keyId, createdOneWeekEarlier, keyRecordOneWeekEarlier); + + bool success = inMemoryKeyMetastoreImpl.TryLoadLatest(keyId, out KeyRecord actualKeyRecord); + + Assert.True(success); + Assert.Equal(keyRecordOneDayLater, actualKeyRecord); + } + + [Fact] + private void TestTryLoadLatestNonExistentKeyIdShouldReturnFalse() + { + const string keyId = "ThisIsMyKey"; + DateTimeOffset created = DateTimeOffset.UtcNow; + var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + + inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + + bool success = inMemoryKeyMetastoreImpl.TryLoadLatest("some non-existent key", out KeyRecord actualKeyRecord); + + Assert.False(success); + Assert.Null(actualKeyRecord); + } + + [Fact] + private void TestStoreWithDuplicateKeyShouldReturnFalse() + { + const string keyId = "ThisIsMyKey"; + DateTimeOffset created = DateTimeOffset.UtcNow; + var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + + Assert.True(inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord)); + Assert.False(inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord)); + } + + [Fact] + private void TestStoreWithIntermediateKeyRecord() + { + const string keyId = "ThisIsMyKey"; + DateTimeOffset created = DateTimeOffset.UtcNow; + var parentKeyMeta = new KeyMeta("parentKey", created.AddDays(-1)); + var keyRecord = KeyRecord.NewIntermediateKeyRecord(created, new byte[] { 1, 2, 3 }, false, parentKeyMeta); + + bool success = inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + + Assert.True(success); + + bool loadSuccess = inMemoryKeyMetastoreImpl.TryLoad(keyId, created, out KeyRecord actualKeyRecord); + Assert.True(loadSuccess); + Assert.Equal(keyRecord, actualKeyRecord); + } + + [Fact] + private void TestGetKeySuffixReturnsEmptyString() + { + string keySuffix = inMemoryKeyMetastoreImpl.GetKeySuffix(); + Assert.Equal(string.Empty, keySuffix); + } + + /// + /// Disposes of the managed resources. + /// + public void Dispose() + { + inMemoryKeyMetastoreImpl?.Dispose(); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Persistence/IKeyMetastore.cs b/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs similarity index 97% rename from csharp/AppEncryption/AppEncryption/Persistence/IKeyMetastore.cs rename to csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs index b9727d265..0139c8cf7 100644 --- a/csharp/AppEncryption/AppEncryption/Persistence/IKeyMetastore.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs @@ -1,7 +1,7 @@ using System; using GoDaddy.Asherah.AppEncryption.Models; -namespace GoDaddy.Asherah.AppEncryption.Persistence +namespace GoDaddy.Asherah.AppEncryption.Metastore { /// /// The KeyMetastore interface provides methods that can be used to load and store system and intermediate keys from a diff --git a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs new file mode 100644 index 000000000..60be2d047 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Runtime.CompilerServices; +using GoDaddy.Asherah.AppEncryption.Models; + +[assembly: InternalsVisibleTo("AppEncryption.Tests")] + +namespace GoDaddy.Asherah.AppEncryption.Metastore +{ + /// + /// Provides a volatile implementation of for key records using a + /// . NOTE: This should NEVER be used in a production environment. + /// + public class InMemoryKeyMetastoreImpl : IKeyMetastore, IDisposable + { + private readonly DataTable dataTable; + + /// + /// Initializes a new instance of the class, with 3 columns. + /// + /// keyId | created | keyRecord + /// ----- | ------- | --------- + /// | | + /// | | + /// + /// Uses 'keyId' and 'created' as the primary key. + /// + public InMemoryKeyMetastoreImpl() + { + dataTable = new DataTable(); + dataTable.Columns.Add("keyId", typeof(string)); + dataTable.Columns.Add("created", typeof(DateTimeOffset)); + dataTable.Columns.Add("keyRecord", typeof(KeyRecord)); + dataTable.PrimaryKey = new[] { dataTable.Columns["keyId"], dataTable.Columns["created"] }; + } + + /// + public bool TryLoad(string keyId, DateTimeOffset created, out KeyRecord keyRecord) + { + lock (dataTable) + { + List dataRows = dataTable.Rows.Cast() + .Where(row => row["keyId"].Equals(keyId) + && row["created"].Equals(created)) + .ToList(); + if (dataRows.Count == 0) + { + keyRecord = null; + return false; + } + + keyRecord = (KeyRecord)dataRows.Single()["keyRecord"]; + return true; + } + } + + /// + public bool TryLoadLatest(string keyId, out KeyRecord keyRecord) + { + lock (dataTable) + { + List dataRows = dataTable.Rows.Cast() + .Where(row => row["keyId"].Equals(keyId)) + .OrderBy(row => row["created"]) + .ToList(); + + // Need to check if empty as Last will throw an exception instead of returning null + if (dataRows.Count == 0) + { + keyRecord = null; + return false; + } + + keyRecord = (KeyRecord)dataRows.Last()["keyRecord"]; + return true; + } + } + + /// + public bool Store(string keyId, DateTimeOffset created, KeyRecord keyRecord) + { + lock (dataTable) + { + List dataRows = dataTable.Rows.Cast() + .Where(row => row["keyId"].Equals(keyId) + && row["created"].Equals(created)) + .ToList(); + if (dataRows.Count > 0) + { + return false; + } + + dataTable.Rows.Add(keyId, created, keyRecord); + return true; + } + } + + /// + public string GetKeySuffix() + { + return string.Empty; + } + + /// + /// Disposes of the managed resources. + /// + public void Dispose() + { + Dispose(true); + } + + /// + /// Disposes of the managed resources. + /// + /// True if called from Dispose, false if called from finalizer. + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + dataTable?.Dispose(); + } + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs b/csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs index 92953bbcb..aa073c46e 100644 --- a/csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs +++ b/csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs @@ -28,5 +28,39 @@ public KeyMeta(string id, DateTimeOffset created) /// Gets the creation time of the key. /// public DateTimeOffset Created { get; } + + /// + public override bool Equals(object obj) + { + if (this == obj) + { + return true; + } + + if (obj == null) + { + return false; + } + + var other = obj as KeyMeta; + if (other == null) + { + return false; + } + + return Id.Equals(other.Id, StringComparison.Ordinal) && Created.Equals(other.Created); + } + + /// + public override int GetHashCode() + { + return (Id, Created).GetHashCode(); + } + + /// + public override string ToString() + { + return "KeyMeta [Id=" + Id + ", Created=" + Created + "]"; + } } } diff --git a/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs b/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs index 14be07fd3..d02cd9770 100644 --- a/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs +++ b/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs @@ -16,7 +16,7 @@ public class KeyRecord /// The encrypted key bytes. /// The revocation status of the encrypted key. /// The metadata for the parent key, if any. - private KeyRecord(DateTimeOffset created, byte[] encryptedKey, bool revoked, KeyMeta parentKeyMeta = null) + private KeyRecord(DateTimeOffset created, byte[] encryptedKey, bool? revoked, KeyMeta parentKeyMeta = null) { Created = created; EncryptedKey = encryptedKey ?? throw new ArgumentNullException(nameof(encryptedKey)); @@ -37,7 +37,7 @@ private KeyRecord(DateTimeOffset created, byte[] encryptedKey, bool revoked, Key /// /// Gets the revocation status of the encrypted key. /// - public bool Revoked { get; } + public bool? Revoked { get; } /// /// Gets the metadata for the parent key, if any. @@ -52,7 +52,7 @@ private KeyRecord(DateTimeOffset created, byte[] encryptedKey, bool revoked, Key /// The encrypted key bytes. /// The revocation status of the encrypted key. /// A new system key record. - public static KeyRecord NewSystemKeyRecord(DateTimeOffset created, byte[] encryptedKey, bool revoked) + public static KeyRecord NewSystemKeyRecord(DateTimeOffset created, byte[] encryptedKey, bool? revoked) { return new KeyRecord(created, encryptedKey, revoked, null); } @@ -66,7 +66,7 @@ public static KeyRecord NewSystemKeyRecord(DateTimeOffset created, byte[] encryp /// The revocation status of the encrypted key. /// The metadata for the parent key. /// A new intermediate key record. - public static KeyRecord NewIntermediateKeyRecord(DateTimeOffset created, byte[] encryptedKey, bool revoked, KeyMeta parentKeyMeta) + public static KeyRecord NewIntermediateKeyRecord(DateTimeOffset created, byte[] encryptedKey, bool? revoked, KeyMeta parentKeyMeta) { if (parentKeyMeta == null) { diff --git a/csharp/AppEncryption/AppEncryption/Persistence/DynamoDbKeyMetastoreAdapter.cs b/csharp/AppEncryption/AppEncryption/Persistence/DynamoDbKeyMetastoreAdapter.cs deleted file mode 100644 index 82b87e1ac..000000000 --- a/csharp/AppEncryption/AppEncryption/Persistence/DynamoDbKeyMetastoreAdapter.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using GoDaddy.Asherah.AppEncryption.Models; -using LanguageExt; -using Newtonsoft.Json.Linq; - -namespace GoDaddy.Asherah.AppEncryption.Persistence -{ - /// - /// Adapter that wraps DynamoDbMetastoreImpl to work with the new IKeyMetastore interface. - /// Converts between KeyRecord and JObject for storage. - /// - public class DynamoDbKeyMetastoreAdapter : IKeyMetastore - { - private readonly DynamoDbMetastoreImpl _dynamoDbMetastore; - - /// - /// Initializes a new instance of the class. - /// - /// - /// The DynamoDB metastore implementation to wrap. - public DynamoDbKeyMetastoreAdapter(DynamoDbMetastoreImpl dynamoDbMetastore) - { - _dynamoDbMetastore = dynamoDbMetastore ?? throw new ArgumentNullException(nameof(dynamoDbMetastore)); - } - - /// - public bool TryLoad(string keyId, DateTimeOffset created, out KeyRecord keyRecord) - { - var option = _dynamoDbMetastore.Load(keyId, created); - if (option.IsSome) - { - keyRecord = ConvertFromJObject((JObject)option); - return true; - } - - keyRecord = null; - return false; - } - - /// - public bool TryLoadLatest(string keyId, out KeyRecord keyRecord) - { - var option = _dynamoDbMetastore.LoadLatest(keyId); - if (option.IsSome) - { - keyRecord = ConvertFromJObject((JObject)option); - return true; - } - - keyRecord = null; - return false; - } - - /// - public bool Store(string keyId, DateTimeOffset created, KeyRecord keyRecord) - { - var jObject = ConvertToJObject(keyRecord); - return _dynamoDbMetastore.Store(keyId, created, jObject); - } - - /// - public string GetKeySuffix() - { - return _dynamoDbMetastore.GetKeySuffix(); - } - - private static KeyRecord ConvertFromJObject(JObject jObject) - { - // Convert JObject back to KeyRecord - // This would need to handle the JSON structure of the existing EnvelopeKeyRecord format - // and convert it to our new KeyRecord model - throw new NotImplementedException("Conversion from JObject to KeyRecord not yet implemented"); - } - - private static JObject ConvertToJObject(KeyRecord keyRecord) - { - // Convert KeyRecord to JObject - // This would need to create the JSON structure expected by the existing DynamoDbMetastoreImpl - throw new NotImplementedException("Conversion from KeyRecord to JObject not yet implemented"); - } - } -} From fb4ab203887144dc930c00a18be3b1dcff6412a9 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 6 Sep 2025 16:34:48 -0700 Subject: [PATCH 03/31] Making the new IKeyMetastore --- .../Regression/DynamoDbGlobalTableTest.cs | 2 +- .../AppEncryption.Tests.csproj | 2 + .../Metastore/InMemoryKeyMetastoreImplTest.cs | 62 ++-- .../Persistence/DynamoDBContainerFixture.cs | 40 --- .../Persistence/DynamoDbMetastoreImplTest.cs | 121 +++----- .../Aws/Metastore/DynamoDbMetastoreTests.cs | 279 ++++++++++++++++++ .../Fixtures/DynamoDBContainerFixture.cs | 39 +++ .../Fixtures/DynamoMetastoreHelper.cs | 178 +++++++++++ csharp/AppEncryption/AppEncryption.slnx | 1 + .../AppEncryption/Metastore/IKeyMetastore.cs | 14 +- .../Metastore/InMemoryKeyMetastoreImpl.cs | 28 +- .../{Models => Metastore}/KeyMeta.cs | 2 +- .../AppEncryption/Metastore/KeyRecord.cs | 50 ++++ .../AppEncryption/Models/KeyRecord.cs | 79 ----- .../Extensions.Aws/Extensions.Aws.csproj | 32 ++ .../Metastore/DynamoDbMetastore.cs | 187 ++++++++++++ .../Metastore/DynamoDbMetastoreOptions.cs | 9 + tartufo.toml | 1 + 18 files changed, 865 insertions(+), 261 deletions(-) delete mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDBContainerFixture.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDBContainerFixture.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoMetastoreHelper.cs rename csharp/AppEncryption/AppEncryption/{Models => Metastore}/KeyMeta.cs (97%) create mode 100644 csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs delete mode 100644 csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs create mode 100644 csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj create mode 100644 csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs create mode 100644 csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/DynamoDbGlobalTableTest.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/DynamoDbGlobalTableTest.cs index 585e873fa..52198358c 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/DynamoDbGlobalTableTest.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/DynamoDbGlobalTableTest.cs @@ -5,7 +5,7 @@ using GoDaddy.Asherah.AppEncryption.IntegrationTests.Utils; using GoDaddy.Asherah.AppEncryption.Persistence; using GoDaddy.Asherah.AppEncryption.Tests; -using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Persistence; +using GoDaddy.Asherah.AppEncryption.Tests.Fixtures; using Xunit; namespace GoDaddy.Asherah.AppEncryption.IntegrationTests.Regression diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index ab9784a1d..d2d527484 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -7,6 +7,7 @@ true Recommended true + latest @@ -33,5 +34,6 @@ + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs index 4c16d312d..810e83ad7 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs @@ -1,5 +1,5 @@ using System; -using GoDaddy.Asherah.AppEncryption.Models; +using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Metastore; using Xunit; @@ -15,101 +15,101 @@ public InMemoryKeyMetastoreImplTest() } [Fact] - private void TestTryLoadAndStoreWithValidKey() + private async Task TestTryLoadAndStoreWithValidKey() { const string keyId = "ThisIsMyKey"; DateTimeOffset created = DateTimeOffset.UtcNow; - var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + var keyRecord = new KeyRecord(created, "test-key-data", false); - inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); - bool success = inMemoryKeyMetastoreImpl.TryLoad(keyId, created, out KeyRecord actualKeyRecord); + var (success, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadAsync(keyId, created); Assert.True(success); Assert.Equal(keyRecord, actualKeyRecord); } [Fact] - private void TestTryLoadAndStoreWithInvalidKey() + private async Task TestTryLoadAndStoreWithInvalidKey() { const string keyId = "ThisIsMyKey"; DateTimeOffset created = DateTimeOffset.UtcNow; - var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + var keyRecord = new KeyRecord(created, "test-key-data", false); - inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); - bool success = inMemoryKeyMetastoreImpl.TryLoad("some non-existent key", created, out KeyRecord actualKeyRecord); + var (success, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadAsync("some non-existent key", created); Assert.False(success); Assert.Null(actualKeyRecord); } [Fact] - private void TestTryLoadLatestMultipleCreatedAndValuesForKeyIdShouldReturnLatest() + private async Task TestTryLoadLatestMultipleCreatedAndValuesForKeyIdShouldReturnLatest() { const string keyId = "ThisIsMyKey"; DateTimeOffset created = DateTimeOffset.UtcNow; - var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + var keyRecord = new KeyRecord(created, "test-key-data", false); - inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); DateTimeOffset createdOneHourLater = created.AddHours(1); - var keyRecordOneHourLater = KeyRecord.NewSystemKeyRecord(createdOneHourLater, new byte[] { 4, 5, 6 }, false); - inMemoryKeyMetastoreImpl.Store(keyId, createdOneHourLater, keyRecordOneHourLater); + var keyRecordOneHourLater = new KeyRecord(createdOneHourLater, "test-key-data-hour", false); + await inMemoryKeyMetastoreImpl.StoreAsync(keyId, createdOneHourLater, keyRecordOneHourLater); DateTimeOffset createdOneDayLater = created.AddDays(1); - var keyRecordOneDayLater = KeyRecord.NewSystemKeyRecord(createdOneDayLater, new byte[] { 7, 8, 9 }, false); - inMemoryKeyMetastoreImpl.Store(keyId, createdOneDayLater, keyRecordOneDayLater); + var keyRecordOneDayLater = new KeyRecord(createdOneDayLater, "test-key-data-day", false); + await inMemoryKeyMetastoreImpl.StoreAsync(keyId, createdOneDayLater, keyRecordOneDayLater); DateTimeOffset createdOneWeekEarlier = created.AddDays(-7); - var keyRecordOneWeekEarlier = KeyRecord.NewSystemKeyRecord(createdOneWeekEarlier, new byte[] { 10, 11, 12 }, false); - inMemoryKeyMetastoreImpl.Store(keyId, createdOneWeekEarlier, keyRecordOneWeekEarlier); + var keyRecordOneWeekEarlier = new KeyRecord(createdOneWeekEarlier, "test-key-data-week", false); + await inMemoryKeyMetastoreImpl.StoreAsync(keyId, createdOneWeekEarlier, keyRecordOneWeekEarlier); - bool success = inMemoryKeyMetastoreImpl.TryLoadLatest(keyId, out KeyRecord actualKeyRecord); + var (success, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadLatestAsync(keyId); Assert.True(success); Assert.Equal(keyRecordOneDayLater, actualKeyRecord); } [Fact] - private void TestTryLoadLatestNonExistentKeyIdShouldReturnFalse() + private async Task TestTryLoadLatestNonExistentKeyIdShouldReturnFalse() { const string keyId = "ThisIsMyKey"; DateTimeOffset created = DateTimeOffset.UtcNow; - var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + var keyRecord = new KeyRecord(created, "test-key-data", false); - inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); - bool success = inMemoryKeyMetastoreImpl.TryLoadLatest("some non-existent key", out KeyRecord actualKeyRecord); + var (success, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadLatestAsync("some non-existent key"); Assert.False(success); Assert.Null(actualKeyRecord); } [Fact] - private void TestStoreWithDuplicateKeyShouldReturnFalse() + private async Task TestStoreWithDuplicateKeyShouldReturnFalse() { const string keyId = "ThisIsMyKey"; DateTimeOffset created = DateTimeOffset.UtcNow; - var keyRecord = KeyRecord.NewSystemKeyRecord(created, new byte[] { 1, 2, 3 }, false); + var keyRecord = new KeyRecord(created, "test-key-data", false); - Assert.True(inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord)); - Assert.False(inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord)); + Assert.True(await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord)); + Assert.False(await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord)); } [Fact] - private void TestStoreWithIntermediateKeyRecord() + private async Task TestStoreWithIntermediateKeyRecord() { const string keyId = "ThisIsMyKey"; DateTimeOffset created = DateTimeOffset.UtcNow; var parentKeyMeta = new KeyMeta("parentKey", created.AddDays(-1)); - var keyRecord = KeyRecord.NewIntermediateKeyRecord(created, new byte[] { 1, 2, 3 }, false, parentKeyMeta); + var keyRecord = new KeyRecord(created, "test-key-data-parent", false, parentKeyMeta); - bool success = inMemoryKeyMetastoreImpl.Store(keyId, created, keyRecord); + bool success = await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); Assert.True(success); - bool loadSuccess = inMemoryKeyMetastoreImpl.TryLoad(keyId, created, out KeyRecord actualKeyRecord); + var (loadSuccess, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadAsync(keyId, created); Assert.True(loadSuccess); Assert.Equal(keyRecord, actualKeyRecord); } diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDBContainerFixture.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDBContainerFixture.cs deleted file mode 100644 index 7efa7bc95..000000000 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDBContainerFixture.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Globalization; -using System.Threading.Tasks; -using Testcontainers.DynamoDb; -using Xunit; - -namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Persistence -{ - public class DynamoDBContainerFixture : IAsyncLifetime - { - private readonly string localServiceUrl; - private readonly DynamoDbContainer dynamoDbContainer; - - public DynamoDBContainerFixture() - { - var disableTestContainers = Convert.ToBoolean(Environment.GetEnvironmentVariable("DISABLE_TESTCONTAINERS"), CultureInfo.InvariantCulture); - - if (disableTestContainers) - { - string hostname = Environment.GetEnvironmentVariable("DYNAMODB_HOSTNAME") ?? "localhost"; - localServiceUrl = $"http://{hostname}:8000"; - } - else - { - Environment.SetEnvironmentVariable("AWS_ACCESS_KEY_ID", "dummykey"); - Environment.SetEnvironmentVariable("AWS_SECRET_ACCESS_KEY", "dummy_secret"); - - dynamoDbContainer = new DynamoDbBuilder() - .WithImage("amazon/dynamodb-local:2.6.0") - .Build(); - } - } - - public Task InitializeAsync() => dynamoDbContainer?.StartAsync() ?? Task.CompletedTask; - - public Task DisposeAsync() => dynamoDbContainer?.StopAsync() ?? Task.CompletedTask; - - public string GetServiceUrl() => dynamoDbContainer?.GetConnectionString() ?? localServiceUrl; - } -} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDbMetastoreImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDbMetastoreImplTest.cs index 8488d12b5..8f269be01 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDbMetastoreImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDbMetastoreImplTest.cs @@ -7,6 +7,7 @@ using Amazon.DynamoDBv2.Model; using Amazon.Runtime; using GoDaddy.Asherah.AppEncryption.Persistence; +using GoDaddy.Asherah.AppEncryption.Tests.Fixtures; using GoDaddy.Asherah.Crypto.Exceptions; using LanguageExt; using Microsoft.Extensions.Logging; @@ -20,29 +21,14 @@ namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Persistence { public class DynamoDbMetastoreImplTest : IClassFixture, IClassFixture, IDisposable { - private const string TestKey = "some_key"; private const string DynamoDbPort = "8000"; private const string Region = "us-west-2"; - private const string TestKeyWithRegionSuffix = TestKey + "_" + Region; + private const string TestKeyWithRegionSuffix = DynamoMetastoreHelper.ExistingTestKey + "_" + Region; private readonly AmazonDynamoDBClient amazonDynamoDbClient; - private readonly Dictionary keyRecord = new Dictionary - { - { - "ParentKeyMeta", new Dictionary - { - { "KeyId", "_SK_api_ecomm" }, - { "Created", 1541461380 }, - } - }, - { "Key", "mWT/x4RvIFVFE2BEYV1IB9FMM8sWN1sK6YN5bS2UyGR+9RSZVTvp/bcQ6PycW6kxYEqrpA+aV4u04jOr" }, - { "Created", 1541461380 }, - }; - - private readonly Table table; private readonly DynamoDbMetastoreImpl dynamoDbMetastoreImpl; - private readonly DateTimeOffset created = DateTimeOffset.Now.AddDays(-1); + private readonly DateTimeOffset created; private string serviceUrl; public DynamoDbMetastoreImplTest(DynamoDBContainerFixture dynamoDbContainerFixture) @@ -55,35 +41,14 @@ public DynamoDbMetastoreImplTest(DynamoDBContainerFixture dynamoDbContainerFixtu }; amazonDynamoDbClient = new AmazonDynamoDBClient(clientConfig); - CreateTableSchema(amazonDynamoDbClient, "EncryptionKey"); + DynamoMetastoreHelper.CreateTableSchema(amazonDynamoDbClient, "EncryptionKey").Wait(); dynamoDbMetastoreImpl = NewBuilder(Region) .WithEndPointConfiguration(serviceUrl, Region) .Build(); - table = (Table)new TableBuilder(amazonDynamoDbClient, dynamoDbMetastoreImpl.TableName) - .AddHashKey(PartitionKey, DynamoDBEntryType.String) - .AddRangeKey(SortKey, DynamoDBEntryType.Numeric) - .Build(); - - JObject jObject = JObject.FromObject(keyRecord); - Document document = new Document - { - [PartitionKey] = TestKey, - [SortKey] = created.ToUnixTimeSeconds(), - [AttributeKeyRecord] = Document.FromJson(jObject.ToString()), - }; - - table.PutItemAsync(document).Wait(); - - document = new Document - { - [PartitionKey] = TestKeyWithRegionSuffix, - [SortKey] = created.ToUnixTimeSeconds(), - [AttributeKeyRecord] = Document.FromJson(jObject.ToString()), - }; - - table.PutItemAsync(document).Wait(); + // Pre-populate test data using helper and capture the created timestamp + created = DynamoMetastoreHelper.PrePopulateTestDataUsingOldMetastore(amazonDynamoDbClient, "EncryptionKey", Region).Result; } public void Dispose() @@ -100,34 +65,14 @@ public void Dispose() } } - private static void CreateTableSchema(AmazonDynamoDBClient client, string tableName) - { - CreateTableRequest request = new CreateTableRequest - { - TableName = tableName, - AttributeDefinitions = new List - { - new AttributeDefinition(PartitionKey, ScalarAttributeType.S), - new AttributeDefinition(SortKey, ScalarAttributeType.N), - }, - KeySchema = new List - { - new KeySchemaElement(PartitionKey, KeyType.HASH), - new KeySchemaElement(SortKey, KeyType.RANGE), - }, - ProvisionedThroughput = new ProvisionedThroughput(1L, 1L), - }; - - CreateTableResponse createTableResponse = client.CreateTableAsync(request).Result; - } [Fact] public void TestLoadSuccess() { - Option actualJsonObject = dynamoDbMetastoreImpl.Load(TestKey, created); + Option actualJsonObject = dynamoDbMetastoreImpl.Load(DynamoMetastoreHelper.ExistingTestKey, created); Assert.True(actualJsonObject.IsSome); - Assert.True(JToken.DeepEquals(JObject.FromObject(keyRecord), (JObject)actualJsonObject)); + Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); } [Fact] @@ -142,7 +87,7 @@ public void TestLoadWithNoResultShouldReturnEmpty() public void TestLoadWithFailureShouldReturnEmpty() { Dispose(); - Option actualJsonObject = dynamoDbMetastoreImpl.Load(TestKey, created); + Option actualJsonObject = dynamoDbMetastoreImpl.Load(DynamoMetastoreHelper.ExistingTestKey, created); Assert.False(actualJsonObject.IsSome); } @@ -150,10 +95,10 @@ public void TestLoadWithFailureShouldReturnEmpty() [Fact] public void TestLoadLatestWithSingleRecord() { - Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest(TestKey); + Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest(DynamoMetastoreHelper.ExistingTestKey); Assert.True(actualJsonObject.IsSome); - Assert.True(JToken.DeepEquals(JObject.FromObject(keyRecord), (JObject)actualJsonObject)); + Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); } [Fact] @@ -164,15 +109,21 @@ public void TestLoadLatestWithSingleRecordAndSuffix() .WithKeySuffix() .Build(); - Option actualJsonObject = dbMetastoreImpl.LoadLatest(TestKey); + Option actualJsonObject = dbMetastoreImpl.LoadLatest(DynamoMetastoreHelper.ExistingTestKey); Assert.True(actualJsonObject.IsSome); - Assert.True(JToken.DeepEquals(JObject.FromObject(keyRecord), (JObject)actualJsonObject)); + Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); } [Fact] public async Task TestLoadLatestWithMultipleRecords() { + // Create a local table instance for this test + Table table = (Table)new TableBuilder(amazonDynamoDbClient, dynamoDbMetastoreImpl.TableName) + .AddHashKey(PartitionKey, DynamoDBEntryType.String) + .AddRangeKey(SortKey, DynamoDBEntryType.Numeric) + .Build(); + DateTimeOffset createdMinusOneHour = created.AddHours(-1); DateTimeOffset createdPlusOneHour = created.AddHours(1); DateTimeOffset createdMinusOneDay = created.AddDays(-1); @@ -181,7 +132,7 @@ public async Task TestLoadLatestWithMultipleRecords() // intentionally mixing up insertion order Document documentPlusOneHour = new Document { - [PartitionKey] = TestKey, + [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, [SortKey] = createdPlusOneHour.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(new JObject { @@ -192,7 +143,7 @@ public async Task TestLoadLatestWithMultipleRecords() Document documentPlusOneDay = new Document { - [PartitionKey] = TestKey, + [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, [SortKey] = createdPlusOneDay.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(new JObject { @@ -203,7 +154,7 @@ public async Task TestLoadLatestWithMultipleRecords() Document documentMinusOneHour = new Document { - [PartitionKey] = TestKey, + [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, [SortKey] = createdMinusOneHour.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(new JObject { @@ -214,7 +165,7 @@ public async Task TestLoadLatestWithMultipleRecords() Document documentMinusOneDay = new Document { - [PartitionKey] = TestKey, + [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, [SortKey] = createdMinusOneDay.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(new JObject { @@ -223,7 +174,7 @@ public async Task TestLoadLatestWithMultipleRecords() }; await table.PutItemAsync(documentMinusOneDay, CancellationToken.None); - Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest(TestKey); + Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest(DynamoMetastoreHelper.ExistingTestKey); Assert.True(actualJsonObject.IsSome); Assert.True(JToken.DeepEquals(createdPlusOneDay, ((JObject)actualJsonObject).GetValue("mytime"))); @@ -241,7 +192,7 @@ public void TestLoadLatestWithNoResultShouldReturnEmpty() public void TestLoadLatestWithFailureShouldReturnEmpty() { Dispose(); - Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest(TestKey); + Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest(DynamoMetastoreHelper.ExistingTestKey); Assert.False(actualJsonObject.IsSome); } @@ -249,7 +200,7 @@ public void TestLoadLatestWithFailureShouldReturnEmpty() [Fact] public void TestStore() { - bool actualValue = dynamoDbMetastoreImpl.Store(TestKey, DateTimeOffset.Now, JObject.FromObject(keyRecord)); + bool actualValue = dynamoDbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); Assert.True(actualValue); } @@ -261,7 +212,7 @@ public void TestStoreWithSuffixSuccess() .WithEndPointConfiguration(serviceUrl, Region) .WithKeySuffix() .Build(); - bool actualValue = dbMetastoreImpl.Store(TestKey, DateTimeOffset.Now, JObject.FromObject(keyRecord)); + bool actualValue = dbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); Assert.True(actualValue); } @@ -278,7 +229,7 @@ public void TestStoreWithClientProvidedExternally() var dbMetastoreImpl = NewBuilder(Region) .WithDynamoDbClient(client) .Build(); - bool actualValue = dbMetastoreImpl.Store(TestKey, DateTimeOffset.Now, JObject.FromObject(keyRecord)); + bool actualValue = dbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); Assert.True(actualValue); } @@ -288,15 +239,15 @@ public void TestStoreWithDbErrorShouldThrowException() { Dispose(); Assert.Throws(() => - dynamoDbMetastoreImpl.Store(TestKey, DateTimeOffset.Now, JObject.FromObject(keyRecord))); + dynamoDbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord))); } [Fact] public void TestStoreWithDuplicateShouldReturnFalse() { DateTimeOffset now = DateTimeOffset.Now; - bool firstAttempt = dynamoDbMetastoreImpl.Store(TestKey, now, JObject.FromObject(keyRecord)); - bool secondAttempt = dynamoDbMetastoreImpl.Store(TestKey, now, JObject.FromObject(keyRecord)); + bool firstAttempt = dynamoDbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); + bool secondAttempt = dynamoDbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); Assert.True(firstAttempt); Assert.False(secondAttempt); @@ -387,17 +338,17 @@ public async Task TestBuilderPathWithTableName() AuthenticationRegion = "us-west-2", }; AmazonDynamoDBClient tempDynamoDbClient = new AmazonDynamoDBClient(amazonDynamoDbConfig); - CreateTableSchema(tempDynamoDbClient, tempTableName); + await DynamoMetastoreHelper.CreateTableSchema(tempDynamoDbClient, tempTableName); // Put the object in temp table Table tempTable = (Table)new TableBuilder(tempDynamoDbClient, tempTableName) .AddHashKey(PartitionKey, DynamoDBEntryType.String) .AddRangeKey(SortKey, DynamoDBEntryType.Numeric) .Build(); - JObject jObject = JObject.FromObject(keyRecord); + JObject jObject = JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord); Document document = new Document { - [PartitionKey] = TestKey, + [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, [SortKey] = created.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(jObject.ToString()), }; @@ -408,11 +359,11 @@ public async Task TestBuilderPathWithTableName() .WithEndPointConfiguration(serviceUrl, "us-west-2") .WithTableName(tempTableName) .Build(); - Option actualJsonObject = dbMetastoreImpl.Load(TestKey, created); + Option actualJsonObject = dbMetastoreImpl.Load(DynamoMetastoreHelper.ExistingTestKey, created); // Verify that we were able to load and successfully decrypt the item from the metastore object created withTableName Assert.True(actualJsonObject.IsSome); - Assert.True(JToken.DeepEquals(JObject.FromObject(keyRecord), (JObject)actualJsonObject)); + Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); } [Fact] diff --git a/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs b/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs new file mode 100644 index 000000000..ed10803c9 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.AppEncryption.Tests.Fixtures; +using Moq; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.Extensions.Aws.Metastore; + +public class DynamoDbMetastoreTests : IClassFixture, IDisposable +{ + private const string TestTableName = "TestKeysTable"; + private const string TestRegion = "us-west-2"; + + private readonly AmazonDynamoDBClient _amazonDynamoDbClient; + private readonly DynamoDbMetastore _dynamoDbMetastore; + private readonly DynamoDbMetastoreOptions _options; + private readonly DateTimeOffset _created; + + public DynamoDbMetastoreTests(DynamoDBContainerFixture dynamoDbContainerFixture) + { + string serviceUrl = dynamoDbContainerFixture.GetServiceUrl(); + AmazonDynamoDBConfig clientConfig = new AmazonDynamoDBConfig + { + ServiceURL = serviceUrl, + AuthenticationRegion = TestRegion, + }; + _amazonDynamoDbClient = new AmazonDynamoDBClient(clientConfig); + + DynamoMetastoreHelper.CreateTableSchema(_amazonDynamoDbClient, TestTableName).Wait(); + + _options = new DynamoDbMetastoreOptions(TestTableName); + _dynamoDbMetastore = new DynamoDbMetastore(_amazonDynamoDbClient, _options); + + // Pre-populate test data using helper and capture the created timestamp + _created = DynamoMetastoreHelper.PrePopulateTestDataUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion).Result; + } + + public void Dispose() + { + try + { + DeleteTableResponse deleteTableResponse = _amazonDynamoDbClient + .DeleteTableAsync(TestTableName) + .Result; + } + catch (AggregateException) + { + // There is no such table. + } + } + + private static void VerifyKeyRecordMatchesExpected(KeyRecord loadedKeyRecord) + { + // Test Key property + Assert.Equal((string)DynamoMetastoreHelper.ExistingKeyRecord["Key"], loadedKeyRecord.Key); + + // Test Created property + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds((long)(int)DynamoMetastoreHelper.ExistingKeyRecord["Created"]), loadedKeyRecord.Created); + + // Test Revoked property (should be null since not in test data) + Assert.Null(loadedKeyRecord.Revoked); + + // Test ParentKeyMeta property + Assert.NotNull(loadedKeyRecord.ParentKeyMeta); + var expectedParentKeyMeta = (Dictionary)DynamoMetastoreHelper.ExistingKeyRecord["ParentKeyMeta"]; + Assert.Equal((string)expectedParentKeyMeta["KeyId"], loadedKeyRecord.ParentKeyMeta.Id); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds((long)(int)expectedParentKeyMeta["Created"]), loadedKeyRecord.ParentKeyMeta.Created); + } + + private DynamoDbMetastore CreateMetastoreWithBrokenDynamoClient() + { + var mockDynamoDbClient = new Mock(); + + // Mock GetItemAsync to throw generic Exception + mockDynamoDbClient.Setup(x => x.GetItemAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("boom!")); + + // Mock QueryAsync to throw generic Exception + mockDynamoDbClient.Setup(x => x.QueryAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("boom!")); + + return new DynamoDbMetastore(mockDynamoDbClient.Object, _options); + } + + private DynamoDbMetastore CreateMetastoreWithBrokenDynamoClientForStore() + { + var mockDynamoDbClient = new Mock(); + + // Mock PutItemAsync to throw generic Exception for Store operations + mockDynamoDbClient.Setup(x => x.PutItemAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("boom!")); + + return new DynamoDbMetastore(mockDynamoDbClient.Object, _options); + } + + [Fact] + public async Task TestLoadSuccess() + { + var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadAsync(DynamoMetastoreHelper.ExistingTestKey, _created); + + Assert.True(found); + Assert.NotNull(loadedKeyRecord); + VerifyKeyRecordMatchesExpected(loadedKeyRecord); + } + + [Fact] + public async Task TestLoadWithNoResultShouldReturnFalse() + { + var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadAsync("fake_key", _created); + + Assert.False(found); + Assert.Null(loadedKeyRecord); + } + + [Fact] + public async Task TestLoadLatestWithSingleRecord() + { + var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadLatestAsync(DynamoMetastoreHelper.ExistingTestKey); + + Assert.True(found); + Assert.NotNull(loadedKeyRecord); + VerifyKeyRecordMatchesExpected(loadedKeyRecord); + } + + [Fact] + public async Task TestLoadLatestWithNoResultShouldReturnFalse() + { + var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadLatestAsync("fake_key"); + + Assert.False(found); + Assert.Null(loadedKeyRecord); + } + + [Fact] + public async Task TestLoadLatestWithMultipleRecords() + { + // Create multiple records with different timestamps + DateTimeOffset createdMinusOneHour = _created.AddHours(-1); + DateTimeOffset createdPlusOneHour = _created.AddHours(1); + DateTimeOffset createdMinusOneDay = _created.AddDays(-1); + DateTimeOffset createdPlusOneDay = _created.AddDays(1); + + // Create test KeyRecord objects + var keyRecordMinusOneHour = new KeyRecord(createdMinusOneHour, "key_minus_one_hour", null, null); + var keyRecordPlusOneHour = new KeyRecord(createdPlusOneHour, "key_plus_one_hour", null, null); + var keyRecordMinusOneDay = new KeyRecord(createdMinusOneDay, "key_minus_one_day", null, null); + var keyRecordPlusOneDay = new KeyRecord(createdPlusOneDay, "key_plus_one_day", null, null); + + // Insert records using the old metastore (intentionally mixing up insertion order) + await DynamoMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoMetastoreHelper.ExistingTestKey, createdPlusOneHour, keyRecordPlusOneHour); + await DynamoMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoMetastoreHelper.ExistingTestKey, createdPlusOneDay, keyRecordPlusOneDay); + await DynamoMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoMetastoreHelper.ExistingTestKey, createdMinusOneHour, keyRecordMinusOneHour); + await DynamoMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoMetastoreHelper.ExistingTestKey, createdMinusOneDay, keyRecordMinusOneDay); + + // Test that LoadLatest returns the record with the latest timestamp + var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadLatestAsync(DynamoMetastoreHelper.ExistingTestKey); + + Assert.True(found); + Assert.NotNull(loadedKeyRecord); + Assert.Equal("key_plus_one_day", loadedKeyRecord.Key); + // Compare Unix timestamps since DynamoDB stores timestamps as Unix seconds + Assert.Equal(createdPlusOneDay.ToUnixTimeSeconds(), loadedKeyRecord.Created.ToUnixTimeSeconds()); + } + + [Fact] + public async Task TestLoadWithFailureShouldThrowException() + { + var brokenMetastore = CreateMetastoreWithBrokenDynamoClient(); + + await Assert.ThrowsAsync( + () => brokenMetastore.TryLoadAsync(DynamoMetastoreHelper.ExistingTestKey, _created)); + } + + [Fact] + public async Task TestLoadLatestWithFailureShouldThrowException() + { + var brokenMetastore = CreateMetastoreWithBrokenDynamoClient(); + + await Assert.ThrowsAsync( + () => brokenMetastore.TryLoadLatestAsync(DynamoMetastoreHelper.ExistingTestKey)); + } + + [Fact] + public void GetKeySuffixShouldReturnRegionEndpointName() + { + // Act + var result = _dynamoDbMetastore.GetKeySuffix(); + + // Assert + Assert.Null(result); + } + + [Theory] + [InlineData(null, true)] // null Revoked, with ParentKeyMeta + [InlineData(false, true)] // false Revoked, with ParentKeyMeta + [InlineData(true, true)] // true Revoked, with ParentKeyMeta + [InlineData(null, false)] // null Revoked, null ParentKeyMeta + [InlineData(false, false)] // false Revoked, null ParentKeyMeta + [InlineData(true, false)] // true Revoked, null ParentKeyMeta + public async Task TestStore(bool? revoked, bool hasParentKeyMeta) + { + // Arrange + string testKeyId = "test_store_key"; + DateTimeOffset testCreated = DateTimeOffset.Now; + KeyMeta parentKeyMeta = hasParentKeyMeta ? new KeyMeta("parent_key_id", DateTimeOffset.Now.AddDays(-1)) : null; + + var testKeyRecord = new KeyRecord( + testCreated, + "test_encrypted_key_data", + revoked, + parentKeyMeta + ); + + // Act + bool storeResult = await _dynamoDbMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord); + + // Assert + Assert.True(storeResult); + + // Verify we can retrieve the stored record + var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadAsync(testKeyId, testCreated); + Assert.True(found); + Assert.NotNull(loadedKeyRecord); + Assert.Equal(testKeyRecord.Key, loadedKeyRecord.Key); + Assert.Equal(testKeyRecord.Created.ToUnixTimeSeconds(), loadedKeyRecord.Created.ToUnixTimeSeconds()); + Assert.Equal(testKeyRecord.Revoked, loadedKeyRecord.Revoked); + + if (hasParentKeyMeta) + { + Assert.NotNull(loadedKeyRecord.ParentKeyMeta); + Assert.Equal(testKeyRecord.ParentKeyMeta.Id, loadedKeyRecord.ParentKeyMeta.Id); + Assert.Equal(testKeyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds(), loadedKeyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds()); + } + else + { + Assert.Null(loadedKeyRecord.ParentKeyMeta); + } + + // Verify the stored record can also be loaded by the old metastore implementation + DynamoMetastoreHelper.VerifyKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, testKeyId, testKeyRecord); + } + + [Fact] + public async Task TestStoreWithDbErrorShouldThrowException() + { + // Arrange + var brokenMetastore = CreateMetastoreWithBrokenDynamoClientForStore(); + string testKeyId = "test_store_key"; + DateTimeOffset testCreated = DateTimeOffset.Now; + var testKeyRecord = new KeyRecord(testCreated, "test_encrypted_key_data", null, null); + + // Act & Assert + await Assert.ThrowsAsync( + () => brokenMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord)); + } + + [Fact] + public async Task TestStoreWithDuplicateShouldReturnFalse() + { + // Arrange + string testKeyId = "test_duplicate_key"; + DateTimeOffset testCreated = DateTimeOffset.Now; + var testKeyRecord = new KeyRecord(testCreated, "test_encrypted_key_data", null, null); + + // Act + bool firstAttempt = await _dynamoDbMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord); + bool secondAttempt = await _dynamoDbMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord); + + // Assert + Assert.True(firstAttempt); + Assert.False(secondAttempt); + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDBContainerFixture.cs b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDBContainerFixture.cs new file mode 100644 index 000000000..86cde0625 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDBContainerFixture.cs @@ -0,0 +1,39 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Testcontainers.DynamoDb; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.Fixtures; + +public class DynamoDBContainerFixture : IAsyncLifetime +{ + private readonly string _localServiceUrl; + private readonly DynamoDbContainer _dynamoDbContainer; + + public DynamoDBContainerFixture() + { + var disableTestContainers = Convert.ToBoolean(Environment.GetEnvironmentVariable("DISABLE_TESTCONTAINERS"), CultureInfo.InvariantCulture); + + if (disableTestContainers) + { + string hostname = Environment.GetEnvironmentVariable("DYNAMODB_HOSTNAME") ?? "localhost"; + _localServiceUrl = $"http://{hostname}:8000"; + } + else + { + Environment.SetEnvironmentVariable("AWS_ACCESS_KEY_ID", "dummykey"); + Environment.SetEnvironmentVariable("AWS_SECRET_ACCESS_KEY", "dummy_secret"); + + _dynamoDbContainer = new DynamoDbBuilder() + .WithImage("amazon/dynamodb-local:2.6.0") + .Build(); + } + } + + public Task InitializeAsync() => _dynamoDbContainer?.StartAsync() ?? Task.CompletedTask; + + public Task DisposeAsync() => _dynamoDbContainer?.StopAsync() ?? Task.CompletedTask; + + public string GetServiceUrl() => _dynamoDbContainer?.GetConnectionString() ?? _localServiceUrl; +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoMetastoreHelper.cs b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoMetastoreHelper.cs new file mode 100644 index 000000000..15e06e452 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoMetastoreHelper.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.DocumentModel; +using Amazon.DynamoDBv2.Model; +using App.Metrics; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.AppEncryption.Persistence; +using GoDaddy.Asherah.AppEncryption.Util; +using Newtonsoft.Json.Linq; +using Xunit; +using static GoDaddy.Asherah.AppEncryption.Persistence.DynamoDbMetastoreImpl; + +namespace GoDaddy.Asherah.AppEncryption.Tests.Fixtures; + +public static class DynamoMetastoreHelper +{ + public const string ExistingTestKey = "some_key"; + public static readonly Dictionary ExistingKeyRecord = new Dictionary + { + { + "ParentKeyMeta", new Dictionary + { + { "KeyId", "_SK_api_ecomm" }, + { "Created", 1541461380 }, + } + }, + { "Key", "mWT/x4RvIFVFE2BEYV1IB9FMM8sWN1sK6YN5bS2UyGR+9RSZVTvp/bcQ6PycW6kxYEqrpA+aV4u04jOr" }, + { "Created", 1541461380 }, + }; + + private const string PartitionKey = "Id"; + private const string SortKey = "Created"; + private const string AttributeKeyRecord = "KeyRecord"; + + private static Table CreateTableInstance(IAmazonDynamoDB client, string tableName, string region) + { + // Create the old DynamoDB implementation + DynamoDbMetastoreImpl dynamoDbMetastoreImpl = NewBuilder(region) + .WithEndPointConfiguration(client.Config.ServiceURL, region) + .WithTableName(tableName) + .Build(); + + // Create the table instance + return (Table)new TableBuilder(client, dynamoDbMetastoreImpl.TableName) + .AddHashKey(PartitionKey, DynamoDBEntryType.String) + .AddRangeKey(SortKey, DynamoDBEntryType.Numeric) + .Build(); + } + + private static async Task InsertDocumentAsync(Table table, string keyId, DateTimeOffset created, Dictionary keyRecordDict) + { + JObject jObject = JObject.FromObject(keyRecordDict); + Document document = new Document + { + [PartitionKey] = keyId, + [SortKey] = created.ToUnixTimeSeconds(), + [AttributeKeyRecord] = Document.FromJson(jObject.ToString()), + }; + + await table.PutItemAsync(document); + } + + public static async Task CreateTableSchema(AmazonDynamoDBClient client, string tableName) + { + CreateTableRequest request = new CreateTableRequest + { + TableName = tableName, + AttributeDefinitions = new List + { + new AttributeDefinition(PartitionKey, ScalarAttributeType.S), + new AttributeDefinition(SortKey, ScalarAttributeType.N), + }, + KeySchema = new List + { + new KeySchemaElement(PartitionKey, KeyType.HASH), + new KeySchemaElement(SortKey, KeyType.RANGE), + }, + ProvisionedThroughput = new ProvisionedThroughput(1L, 1L), + }; + + CreateTableResponse createTableResponse = await client.CreateTableAsync(request); + } + + public static async Task PrePopulateTestDataUsingOldMetastore(IAmazonDynamoDB client, string tableName, string region) + { + Table table = CreateTableInstance(client, tableName, region); + + // Test data + string testKeyWithRegionSuffix = ExistingTestKey + "_" + region; + DateTimeOffset created = DateTimeOffset.Now.AddDays(-1); + + // Pre-populate test data + await InsertDocumentAsync(table, ExistingTestKey, created, ExistingKeyRecord); + await InsertDocumentAsync(table, testKeyWithRegionSuffix, created, ExistingKeyRecord); + + return created; + } + + public static async Task AddKeyRecordUsingOldMetastore(IAmazonDynamoDB client, string tableName, string region, string keyId, DateTimeOffset created, KeyRecord keyRecord) + { + Table table = CreateTableInstance(client, tableName, region); + + // Convert KeyRecord to Dictionary format + Dictionary keyRecordDict = new Dictionary + { + { "Key", keyRecord.Key }, + { "Created", keyRecord.Created.ToUnixTimeSeconds() } + }; + + // Add Revoked if it has a value + if (keyRecord.Revoked.HasValue) + { + keyRecordDict["Revoked"] = keyRecord.Revoked.Value; + } + + // Add ParentKeyMeta if it exists + if (keyRecord.ParentKeyMeta != null) + { + keyRecordDict["ParentKeyMeta"] = new Dictionary + { + { "KeyId", keyRecord.ParentKeyMeta.Id }, + { "Created", keyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds() } + }; + } + + // Insert the document + await InsertDocumentAsync(table, keyId, created, keyRecordDict); + } + + public static void VerifyKeyRecordUsingOldMetastore(IAmazonDynamoDB client, string tableName, string region, string keyId, KeyRecord expectedKeyRecord) + { + // Initialize metrics for the old implementation + MetricsUtil.SetMetricsInstance(AppMetrics.CreateDefaultBuilder().Build()); + + // Create the old DynamoDB implementation + DynamoDbMetastoreImpl dynamoDbMetastoreImpl = NewBuilder(region) + .WithEndPointConfiguration(client.Config.ServiceURL, region) + .WithTableName(tableName) + .Build(); + + // Load the key record using the old implementation + var loadedJsonObject = dynamoDbMetastoreImpl.Load(keyId, expectedKeyRecord.Created); + + // Validate that the record was found + Assert.True(loadedJsonObject.IsSome); + var loadedKeyRecord = (JObject)loadedJsonObject; + + // Validate the properties + Assert.Equal(expectedKeyRecord.Key, loadedKeyRecord["Key"].ToString()); + Assert.Equal(expectedKeyRecord.Created.ToUnixTimeSeconds(), loadedKeyRecord["Created"].ToObject()); + + // Validate Revoked + if (expectedKeyRecord.Revoked.HasValue) + { + Assert.True(loadedKeyRecord.ContainsKey("Revoked")); + Assert.Equal(expectedKeyRecord.Revoked.Value, loadedKeyRecord["Revoked"].ToObject()); + } + else + { + Assert.False(loadedKeyRecord.ContainsKey("Revoked")); + } + + // Validate ParentKeyMeta + if (expectedKeyRecord.ParentKeyMeta != null) + { + Assert.True(loadedKeyRecord.ContainsKey("ParentKeyMeta")); + var parentKeyMeta = loadedKeyRecord["ParentKeyMeta"].ToObject(); + Assert.Equal(expectedKeyRecord.ParentKeyMeta.Id, parentKeyMeta["KeyId"].ToString()); + Assert.Equal(expectedKeyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds(), parentKeyMeta["Created"].ToObject()); + } + else + { + Assert.False(loadedKeyRecord.ContainsKey("ParentKeyMeta")); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.slnx b/csharp/AppEncryption/AppEncryption.slnx index 638a093a1..0874b30a5 100644 --- a/csharp/AppEncryption/AppEncryption.slnx +++ b/csharp/AppEncryption/AppEncryption.slnx @@ -2,5 +2,6 @@ + diff --git a/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs b/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs index 0139c8cf7..6768edfa0 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs @@ -1,5 +1,5 @@ using System; -using GoDaddy.Asherah.AppEncryption.Models; +using System.Threading.Tasks; namespace GoDaddy.Asherah.AppEncryption.Metastore { @@ -15,18 +15,16 @@ public interface IKeyMetastore /// /// The keyId to lookup. /// The created time to lookup. - /// The key record if found. - /// True if the key record was found, false otherwise. - bool TryLoad(string keyId, DateTimeOffset created, out KeyRecord keyRecord); + /// A tuple containing a boolean indicating if the key record was found and the key record if found. + Task<(bool found, KeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created); /// /// Attempts to load the latest key record associated with the keyId. /// /// /// The keyId to lookup. - /// The latest key record if found. - /// True if a key record was found, false otherwise. - bool TryLoadLatest(string keyId, out KeyRecord keyRecord); + /// A tuple containing a boolean indicating if a key record was found and the latest key record if found. + Task<(bool found, KeyRecord keyRecord)> TryLoadLatestAsync(string keyId); /// /// Stores the key record using the specified keyId and created time. @@ -37,7 +35,7 @@ public interface IKeyMetastore /// The key record to store. /// True if the store succeeded, false if the store failed for a known condition e.g., trying to save /// a duplicate value should return false, not throw an exception. - bool Store(string keyId, DateTimeOffset created, KeyRecord keyRecord); + Task StoreAsync(string keyId, DateTimeOffset created, KeyRecord keyRecord); /// /// Returns the key suffix or "" if key suffix option is disabled. diff --git a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs index 60be2d047..a6696888b 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs @@ -3,9 +3,7 @@ using System.Data; using System.Linq; using System.Runtime.CompilerServices; -using GoDaddy.Asherah.AppEncryption.Models; - -[assembly: InternalsVisibleTo("AppEncryption.Tests")] +using System.Threading.Tasks; namespace GoDaddy.Asherah.AppEncryption.Metastore { @@ -37,7 +35,7 @@ public InMemoryKeyMetastoreImpl() } /// - public bool TryLoad(string keyId, DateTimeOffset created, out KeyRecord keyRecord) + public Task<(bool found, KeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) { lock (dataTable) { @@ -47,17 +45,16 @@ public bool TryLoad(string keyId, DateTimeOffset created, out KeyRecord keyRecor .ToList(); if (dataRows.Count == 0) { - keyRecord = null; - return false; + return Task.FromResult((false, (KeyRecord)null)); } - keyRecord = (KeyRecord)dataRows.Single()["keyRecord"]; - return true; + var keyRecord = (KeyRecord)dataRows.Single()["keyRecord"]; + return Task.FromResult((true, keyRecord)); } } /// - public bool TryLoadLatest(string keyId, out KeyRecord keyRecord) + public Task<(bool found, KeyRecord keyRecord)> TryLoadLatestAsync(string keyId) { lock (dataTable) { @@ -69,17 +66,16 @@ public bool TryLoadLatest(string keyId, out KeyRecord keyRecord) // Need to check if empty as Last will throw an exception instead of returning null if (dataRows.Count == 0) { - keyRecord = null; - return false; + return Task.FromResult((false, (KeyRecord)null)); } - keyRecord = (KeyRecord)dataRows.Last()["keyRecord"]; - return true; + var keyRecord = (KeyRecord)dataRows.Last()["keyRecord"]; + return Task.FromResult((true, keyRecord)); } } /// - public bool Store(string keyId, DateTimeOffset created, KeyRecord keyRecord) + public Task StoreAsync(string keyId, DateTimeOffset created, KeyRecord keyRecord) { lock (dataTable) { @@ -89,11 +85,11 @@ public bool Store(string keyId, DateTimeOffset created, KeyRecord keyRecord) .ToList(); if (dataRows.Count > 0) { - return false; + return Task.FromResult(false); } dataTable.Rows.Add(keyId, created, keyRecord); - return true; + return Task.FromResult(true); } } diff --git a/csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs b/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs similarity index 97% rename from csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs rename to csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs index aa073c46e..b14ce975d 100644 --- a/csharp/AppEncryption/AppEncryption/Models/KeyMeta.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs @@ -1,6 +1,6 @@ using System; -namespace GoDaddy.Asherah.AppEncryption.Models +namespace GoDaddy.Asherah.AppEncryption.Metastore { /// /// Represents metadata for a parent key. diff --git a/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs b/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs new file mode 100644 index 000000000..a34cac15e --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs @@ -0,0 +1,50 @@ +using System; + +namespace GoDaddy.Asherah.AppEncryption.Metastore +{ + /// + /// Represents a key record with basic properties for encrypted keys. + /// System KeyRecords will not have a ParentKeyMeta, while Intermediate KeyRecords will have a ParentKeyMeta. + /// + public class KeyRecord + { + /// + /// Initializes a new instance of the class. + /// System KeyRecords will not have a ParentKeyMeta, while Intermediate KeyRecords will have a ParentKeyMeta. + /// + /// + /// Creation time of the encrypted key. + /// The encoded/encrypted key data as a string. + /// The revocation status of the encrypted key. + /// The metadata for the parent key, if any. Defaults to null for system keys. + public KeyRecord(DateTimeOffset created, string key, bool? revoked, KeyMeta parentKeyMeta = null) + { + Created = created; + Key = key ?? throw new ArgumentNullException(nameof(key)); + Revoked = revoked; + ParentKeyMeta = parentKeyMeta; + } + + /// + /// Gets the creation time of the encrypted key. + /// + public DateTimeOffset Created { get; } + + /// + /// Gets the encoded/encrypted key data as a string. + /// + public string Key { get; } + + /// + /// Gets the revocation status of the encrypted key. + /// + public bool? Revoked { get; } + + /// + /// Gets the metadata for the parent key, if any. + /// + public KeyMeta ParentKeyMeta { get; } + + + } +} diff --git a/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs b/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs deleted file mode 100644 index d02cd9770..000000000 --- a/csharp/AppEncryption/AppEncryption/Models/KeyRecord.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; - -namespace GoDaddy.Asherah.AppEncryption.Models -{ - /// - /// Represents a key record with basic properties for encrypted keys. - /// System KeyRecords will not have a ParentKeyMeta, while Intermediate KeyRecords will have a ParentKeyMeta. - /// - public class KeyRecord - { - /// - /// Initializes a new instance of the class. - /// - /// - /// Creation time of the encrypted key. - /// The encrypted key bytes. - /// The revocation status of the encrypted key. - /// The metadata for the parent key, if any. - private KeyRecord(DateTimeOffset created, byte[] encryptedKey, bool? revoked, KeyMeta parentKeyMeta = null) - { - Created = created; - EncryptedKey = encryptedKey ?? throw new ArgumentNullException(nameof(encryptedKey)); - Revoked = revoked; - ParentKeyMeta = parentKeyMeta; - } - - /// - /// Gets the creation time of the encrypted key. - /// - public DateTimeOffset Created { get; } - - /// - /// Gets the encrypted key bytes. - /// - public byte[] EncryptedKey { get; } - - /// - /// Gets the revocation status of the encrypted key. - /// - public bool? Revoked { get; } - - /// - /// Gets the metadata for the parent key, if any. - /// - public KeyMeta ParentKeyMeta { get; } - - /// - /// Creates a system key record with no parent key metadata. - /// - /// - /// Creation time of the encrypted key. - /// The encrypted key bytes. - /// The revocation status of the encrypted key. - /// A new system key record. - public static KeyRecord NewSystemKeyRecord(DateTimeOffset created, byte[] encryptedKey, bool? revoked) - { - return new KeyRecord(created, encryptedKey, revoked, null); - } - - /// - /// Creates an intermediate key record with parent key metadata. - /// - /// - /// Creation time of the encrypted key. - /// The encrypted key bytes. - /// The revocation status of the encrypted key. - /// The metadata for the parent key. - /// A new intermediate key record. - public static KeyRecord NewIntermediateKeyRecord(DateTimeOffset created, byte[] encryptedKey, bool? revoked, KeyMeta parentKeyMeta) - { - if (parentKeyMeta == null) - { - throw new ArgumentNullException(nameof(parentKeyMeta)); - } - - return new KeyRecord(created, encryptedKey, revoked, parentKeyMeta); - } - } -} diff --git a/csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj b/csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj new file mode 100644 index 000000000..1adea69b7 --- /dev/null +++ b/csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj @@ -0,0 +1,32 @@ + + + GoDaddy.Asherah.AppEncryption.Extensions.Aws + AppEncryption.Extensions.Aws + GoDaddy + GoDaddy + AWS extensions for Application level envelope encryption SDK for C# + net8.0;net9.0 + latest + + + GoDaddy.Asherah.AppEncryption.Extensions.Aws + true + true + Recommended + true + + False + https://github.com/godaddy/asherah + https://github.com/godaddy/asherah/tree/main/csharp/AppEncryption + MIT + true + snupkg + + + + + + + + + diff --git a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs new file mode 100644 index 000000000..1c874a4ff --- /dev/null +++ b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.Crypto.Exceptions; + +namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore; + +/// +/// Provides an AWS DynamoDB based implementation of to store and retrieve system keys +/// and intermediate keys as values. +/// +/// The AWS DynamoDB client to use for operations. +/// Configuration options for the metastore. +public class DynamoDbMetastore(IAmazonDynamoDB dynamoDbClient, DynamoDbMetastoreOptions options) : IKeyMetastore +{ + internal const string PartitionKey = "Id"; + internal const string SortKey = "Created"; + internal const string AttributeKeyRecord = "KeyRecord"; + + /// + public async Task<(bool found, KeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) + { + var request = new GetItemRequest + { + TableName = options.KeyRecordTableName, + Key = new Dictionary + { + [PartitionKey] = new AttributeValue { S = keyId }, + [SortKey] = new AttributeValue { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + }, + ProjectionExpression = AttributeKeyRecord, + ConsistentRead = true + }; + + var response = await dynamoDbClient.GetItemAsync(request); + + if (response.Item != null && response.Item.TryGetValue(AttributeKeyRecord, out var keyRecordAttribute)) + { + var keyRecord = ConvertAttributeValueToKeyRecord(keyRecordAttribute); + return (true, keyRecord); + } + + return (false, null); + } + + /// + public async Task<(bool found, KeyRecord keyRecord)> TryLoadLatestAsync(string keyId) + { + var request = new QueryRequest + { + TableName = options.KeyRecordTableName, + KeyConditionExpression = $"{PartitionKey} = :keyId", + ExpressionAttributeValues = new Dictionary + { + [":keyId"] = new AttributeValue { S = keyId } + }, + ProjectionExpression = AttributeKeyRecord, + ScanIndexForward = false, // Sort descending (latest first) + Limit = 1, // Only get the latest item + ConsistentRead = true + }; + + var response = await dynamoDbClient.QueryAsync(request); + + if (response.Items != null && response.Items.Count > 0) + { + var item = response.Items[0]; + if (item.TryGetValue(AttributeKeyRecord, out var keyRecordAttribute)) + { + var keyRecord = ConvertAttributeValueToKeyRecord(keyRecordAttribute); + return (true, keyRecord); + } + } + + return (false, null); + } + + /// + public async Task StoreAsync(string keyId, DateTimeOffset created, KeyRecord keyRecord) + { + try + { + var keyRecordMap = new Dictionary + { + ["Key"] = new AttributeValue { S = keyRecord.Key }, + ["Created"] = new AttributeValue { N = keyRecord.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + }; + + // Only add Revoked if it has a value + if (keyRecord.Revoked.HasValue) + { + keyRecordMap["Revoked"] = new AttributeValue { BOOL = keyRecord.Revoked.Value }; + } + + // Only add ParentKeyMeta if it exists + if (keyRecord.ParentKeyMeta != null) + { + keyRecordMap["ParentKeyMeta"] = new AttributeValue + { + M = new Dictionary + { + ["KeyId"] = new AttributeValue { S = keyRecord.ParentKeyMeta.Id }, + ["Created"] = new AttributeValue { N = keyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + } + }; + } + + var keyRecordAttribute = new AttributeValue { M = keyRecordMap }; + + var item = new Dictionary + { + [PartitionKey] = new AttributeValue { S = keyId }, + [SortKey] = new AttributeValue { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, + [AttributeKeyRecord] = keyRecordAttribute + }; + + var request = new PutItemRequest + { + TableName = options.KeyRecordTableName, + Item = item, + ConditionExpression = $"attribute_not_exists({PartitionKey})" + }; + + await dynamoDbClient.PutItemAsync(request); + return true; + } + catch (ConditionalCheckFailedException) + { + return false; + } + } + + /// + public string GetKeySuffix() + { + return dynamoDbClient.Config.RegionEndpoint?.SystemName; + } + + private static KeyRecord ConvertAttributeValueToKeyRecord(AttributeValue keyRecordAttribute) + { + if (keyRecordAttribute.M == null) + { + throw new ArgumentException("KeyRecord attribute must be a Map", nameof(keyRecordAttribute)); + } + + var map = keyRecordAttribute.M; + + if (!map.TryGetValue("Key", out var keyAttr) || keyAttr.S == null) + { + throw new ArgumentException("KeyRecord must contain Key field", nameof(keyRecordAttribute)); + } + var keyString = keyAttr.S; + + // Extract Created (Unix timestamp) + if (!map.TryGetValue("Created", out var createdAttr) || createdAttr.N == null) + { + throw new ArgumentException("KeyRecord must contain Created field", nameof(keyRecordAttribute)); + } + var created = DateTimeOffset.FromUnixTimeSeconds(long.Parse(createdAttr.N, CultureInfo.InvariantCulture)); + + // Extract Revoked (optional boolean) + bool? revoked = null; + if (map.TryGetValue("Revoked", out var revokedAttr) && revokedAttr.BOOL.HasValue) + { + revoked = revokedAttr.BOOL.Value; + } + + // Extract ParentKeyMeta (optional map) + KeyMeta parentKeyMeta = null; + if (map.TryGetValue("ParentKeyMeta", out var parentMetaAttr) && parentMetaAttr.M != null) + { + var parentMetaMap = parentMetaAttr.M; + if (parentMetaMap.TryGetValue("KeyId", out var parentKeyIdAttr) && parentMetaMap.TryGetValue("Created", out var parentCreatedAttr)) + { + var parentKeyId = parentKeyIdAttr.S; + var parentCreated = DateTimeOffset.FromUnixTimeSeconds(long.Parse(parentCreatedAttr.N, CultureInfo.InvariantCulture)); + parentKeyMeta = new KeyMeta(parentKeyId, parentCreated); + } + } + + return new KeyRecord(created, keyString, revoked, parentKeyMeta); + } +} diff --git a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs new file mode 100644 index 000000000..ad654e2e9 --- /dev/null +++ b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs @@ -0,0 +1,9 @@ +using System; + +namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore; + +/// +/// Configuration options for DynamoDbMetastore. +/// +/// The name of the DynamoDB table to store key records. +public record DynamoDbMetastoreOptions(string KeyRecordTableName); diff --git a/tartufo.toml b/tartufo.toml index 7aafaa998..8818f77ee 100644 --- a/tartufo.toml +++ b/tartufo.toml @@ -52,6 +52,7 @@ 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 = "350ebba07491fe87c1abe10b84e1991237ddd8ee69f46304a921f7a6784d617e", 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 33ba57f8034c8543ae873a11bdf310796d05d707 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 6 Sep 2025 17:54:33 -0700 Subject: [PATCH 04/31] Code quality fixes --- .../Regression/DynamoDbGlobalTableTest.cs | 4 +- .../Persistence/DynamoDbMetastoreImplTest.cs | 195 +++++++++--------- .../Aws/Metastore/DynamoDbMetastoreTests.cs | 86 ++++---- .../Fixtures/DynamoDBContainerFixture.cs | 8 +- ...reHelper.cs => DynamoDbMetastoreHelper.cs} | 57 ++--- .../Metastore/InMemoryKeyMetastoreImpl.cs | 1 - .../Metastore/DynamoDbMetastore.cs | 43 ++-- .../Metastore/DynamoDbMetastoreOptions.cs | 2 - tartufo.toml | 1 + 9 files changed, 198 insertions(+), 199 deletions(-) rename csharp/AppEncryption/AppEncryption.Tests/Fixtures/{DynamoMetastoreHelper.cs => DynamoDbMetastoreHelper.cs} (79%) diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/DynamoDbGlobalTableTest.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/DynamoDbGlobalTableTest.cs index 52198358c..fd8afe829 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/DynamoDbGlobalTableTest.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/DynamoDbGlobalTableTest.cs @@ -11,7 +11,7 @@ namespace GoDaddy.Asherah.AppEncryption.IntegrationTests.Regression { [Collection("Configuration collection")] - public class DynamoDbGlobalTableTest : IClassFixture, IClassFixture, IDisposable + public class DynamoDbGlobalTableTest : IClassFixture, IClassFixture, IDisposable { private const string PartitionKey = "Id"; private const string SortKey = "Created"; @@ -23,7 +23,7 @@ public class DynamoDbGlobalTableTest : IClassFixture, private AmazonDynamoDBClient tempDynamoDbClient; - public DynamoDbGlobalTableTest(DynamoDBContainerFixture dynamoDbContainerFixture, ConfigFixture configFixture) + public DynamoDbGlobalTableTest(DynamoDbContainerFixture dynamoDbContainerFixture, ConfigFixture configFixture) { serviceUrl = dynamoDbContainerFixture.GetServiceUrl(); this.configFixture = configFixture; diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDbMetastoreImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDbMetastoreImplTest.cs index 8f269be01..22dc17cd4 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDbMetastoreImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Persistence/DynamoDbMetastoreImplTest.cs @@ -1,63 +1,60 @@ using System; -using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.DocumentModel; -using Amazon.DynamoDBv2.Model; using Amazon.Runtime; using GoDaddy.Asherah.AppEncryption.Persistence; using GoDaddy.Asherah.AppEncryption.Tests.Fixtures; using GoDaddy.Asherah.Crypto.Exceptions; -using LanguageExt; using Microsoft.Extensions.Logging; using Moq; using Newtonsoft.Json.Linq; using Xunit; -using static GoDaddy.Asherah.AppEncryption.Persistence.DynamoDbMetastoreImpl; - namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Persistence { - public class DynamoDbMetastoreImplTest : IClassFixture, IClassFixture, IDisposable + [ExcludeFromCodeCoverage] + public class DynamoDbMetastoreImplTest : IClassFixture, IClassFixture, IDisposable { - private const string DynamoDbPort = "8000"; + private const string PartitionKey = "Id"; + private const string SortKey = "Created"; + private const string AttributeKeyRecord = "KeyRecord"; + private const string Region = "us-west-2"; - private const string TestKeyWithRegionSuffix = DynamoMetastoreHelper.ExistingTestKey + "_" + Region; - private readonly AmazonDynamoDBClient amazonDynamoDbClient; + private readonly AmazonDynamoDBClient _amazonDynamoDbClient; - private readonly DynamoDbMetastoreImpl dynamoDbMetastoreImpl; - private readonly DateTimeOffset created; - private string serviceUrl; + private readonly DynamoDbMetastoreImpl _dynamoDbMetastoreImpl; + private readonly DateTimeOffset _created; + private string _serviceUrl; - public DynamoDbMetastoreImplTest(DynamoDBContainerFixture dynamoDbContainerFixture) + public DynamoDbMetastoreImplTest(DynamoDbContainerFixture dynamoDbContainerFixture) { - serviceUrl = dynamoDbContainerFixture.GetServiceUrl(); - AmazonDynamoDBConfig clientConfig = new AmazonDynamoDBConfig + _serviceUrl = dynamoDbContainerFixture.GetServiceUrl(); + var clientConfig = new AmazonDynamoDBConfig { - ServiceURL = serviceUrl, + ServiceURL = _serviceUrl, AuthenticationRegion = "us-west-2", }; - amazonDynamoDbClient = new AmazonDynamoDBClient(clientConfig); + _amazonDynamoDbClient = new AmazonDynamoDBClient(clientConfig); - DynamoMetastoreHelper.CreateTableSchema(amazonDynamoDbClient, "EncryptionKey").Wait(); + DynamoDbMetastoreHelper.CreateTableSchema(_amazonDynamoDbClient, "EncryptionKey").Wait(); - dynamoDbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + _dynamoDbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .Build(); // Pre-populate test data using helper and capture the created timestamp - created = DynamoMetastoreHelper.PrePopulateTestDataUsingOldMetastore(amazonDynamoDbClient, "EncryptionKey", Region).Result; + _created = DynamoDbMetastoreHelper.PrePopulateTestDataUsingOldMetastore(_amazonDynamoDbClient, "EncryptionKey", Region).Result; } public void Dispose() { try { - DeleteTableResponse deleteTableResponse = amazonDynamoDbClient - .DeleteTableAsync(dynamoDbMetastoreImpl.TableName) - .Result; + _ = _amazonDynamoDbClient.DeleteTableAsync(_dynamoDbMetastoreImpl.TableName).Result; } catch (AggregateException) { @@ -69,16 +66,16 @@ public void Dispose() [Fact] public void TestLoadSuccess() { - Option actualJsonObject = dynamoDbMetastoreImpl.Load(DynamoMetastoreHelper.ExistingTestKey, created); + var actualJsonObject = _dynamoDbMetastoreImpl.Load(DynamoDbMetastoreHelper.ExistingTestKey, _created); Assert.True(actualJsonObject.IsSome); - Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); + Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); } [Fact] public void TestLoadWithNoResultShouldReturnEmpty() { - Option actualJsonObject = dynamoDbMetastoreImpl.Load("fake_key", created); + var actualJsonObject = _dynamoDbMetastoreImpl.Load("fake_key", _created); Assert.False(actualJsonObject.IsSome); } @@ -87,7 +84,7 @@ public void TestLoadWithNoResultShouldReturnEmpty() public void TestLoadWithFailureShouldReturnEmpty() { Dispose(); - Option actualJsonObject = dynamoDbMetastoreImpl.Load(DynamoMetastoreHelper.ExistingTestKey, created); + var actualJsonObject = _dynamoDbMetastoreImpl.Load(DynamoDbMetastoreHelper.ExistingTestKey, _created); Assert.False(actualJsonObject.IsSome); } @@ -95,44 +92,44 @@ public void TestLoadWithFailureShouldReturnEmpty() [Fact] public void TestLoadLatestWithSingleRecord() { - Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest(DynamoMetastoreHelper.ExistingTestKey); + var actualJsonObject = _dynamoDbMetastoreImpl.LoadLatest(DynamoDbMetastoreHelper.ExistingTestKey); Assert.True(actualJsonObject.IsSome); - Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); + Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); } [Fact] public void TestLoadLatestWithSingleRecordAndSuffix() { - DynamoDbMetastoreImpl dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .WithKeySuffix() .Build(); - Option actualJsonObject = dbMetastoreImpl.LoadLatest(DynamoMetastoreHelper.ExistingTestKey); + var actualJsonObject = dbMetastoreImpl.LoadLatest(DynamoDbMetastoreHelper.ExistingTestKey); Assert.True(actualJsonObject.IsSome); - Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); + Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); } [Fact] public async Task TestLoadLatestWithMultipleRecords() { // Create a local table instance for this test - Table table = (Table)new TableBuilder(amazonDynamoDbClient, dynamoDbMetastoreImpl.TableName) + var table = new TableBuilder(_amazonDynamoDbClient, _dynamoDbMetastoreImpl.TableName) .AddHashKey(PartitionKey, DynamoDBEntryType.String) .AddRangeKey(SortKey, DynamoDBEntryType.Numeric) .Build(); - DateTimeOffset createdMinusOneHour = created.AddHours(-1); - DateTimeOffset createdPlusOneHour = created.AddHours(1); - DateTimeOffset createdMinusOneDay = created.AddDays(-1); - DateTimeOffset createdPlusOneDay = created.AddDays(1); + var createdMinusOneHour = _created.AddHours(-1); + var createdPlusOneHour = _created.AddHours(1); + var createdMinusOneDay = _created.AddDays(-1); + var createdPlusOneDay = _created.AddDays(1); // intentionally mixing up insertion order - Document documentPlusOneHour = new Document + var documentPlusOneHour = new Document { - [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, + [PartitionKey] = DynamoDbMetastoreHelper.ExistingTestKey, [SortKey] = createdPlusOneHour.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(new JObject { @@ -141,9 +138,9 @@ public async Task TestLoadLatestWithMultipleRecords() }; await table.PutItemAsync(documentPlusOneHour, CancellationToken.None); - Document documentPlusOneDay = new Document + var documentPlusOneDay = new Document { - [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, + [PartitionKey] = DynamoDbMetastoreHelper.ExistingTestKey, [SortKey] = createdPlusOneDay.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(new JObject { @@ -152,9 +149,9 @@ public async Task TestLoadLatestWithMultipleRecords() }; await table.PutItemAsync(documentPlusOneDay, CancellationToken.None); - Document documentMinusOneHour = new Document + var documentMinusOneHour = new Document { - [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, + [PartitionKey] = DynamoDbMetastoreHelper.ExistingTestKey, [SortKey] = createdMinusOneHour.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(new JObject { @@ -163,9 +160,9 @@ public async Task TestLoadLatestWithMultipleRecords() }; await table.PutItemAsync(documentMinusOneHour, CancellationToken.None); - Document documentMinusOneDay = new Document + var documentMinusOneDay = new Document { - [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, + [PartitionKey] = DynamoDbMetastoreHelper.ExistingTestKey, [SortKey] = createdMinusOneDay.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(new JObject { @@ -174,7 +171,7 @@ public async Task TestLoadLatestWithMultipleRecords() }; await table.PutItemAsync(documentMinusOneDay, CancellationToken.None); - Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest(DynamoMetastoreHelper.ExistingTestKey); + var actualJsonObject = _dynamoDbMetastoreImpl.LoadLatest(DynamoDbMetastoreHelper.ExistingTestKey); Assert.True(actualJsonObject.IsSome); Assert.True(JToken.DeepEquals(createdPlusOneDay, ((JObject)actualJsonObject).GetValue("mytime"))); @@ -183,7 +180,7 @@ public async Task TestLoadLatestWithMultipleRecords() [Fact] public void TestLoadLatestWithNoResultShouldReturnEmpty() { - Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest("fake_key"); + var actualJsonObject = _dynamoDbMetastoreImpl.LoadLatest("fake_key"); Assert.False(actualJsonObject.IsSome); } @@ -192,7 +189,7 @@ public void TestLoadLatestWithNoResultShouldReturnEmpty() public void TestLoadLatestWithFailureShouldReturnEmpty() { Dispose(); - Option actualJsonObject = dynamoDbMetastoreImpl.LoadLatest(DynamoMetastoreHelper.ExistingTestKey); + var actualJsonObject = _dynamoDbMetastoreImpl.LoadLatest(DynamoDbMetastoreHelper.ExistingTestKey); Assert.False(actualJsonObject.IsSome); } @@ -200,7 +197,7 @@ public void TestLoadLatestWithFailureShouldReturnEmpty() [Fact] public void TestStore() { - bool actualValue = dynamoDbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); + var actualValue = _dynamoDbMetastoreImpl.Store(DynamoDbMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord)); Assert.True(actualValue); } @@ -208,11 +205,11 @@ public void TestStore() [Fact] public void TestStoreWithSuffixSuccess() { - DynamoDbMetastoreImpl dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .WithKeySuffix() .Build(); - bool actualValue = dbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); + var actualValue = dbMetastoreImpl.Store(DynamoDbMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord)); Assert.True(actualValue); } @@ -222,14 +219,14 @@ public void TestStoreWithClientProvidedExternally() { var client = new AmazonDynamoDBClient(new AmazonDynamoDBConfig { - ServiceURL = serviceUrl, + ServiceURL = _serviceUrl, AuthenticationRegion = Region, }); - var dbMetastoreImpl = NewBuilder(Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) .WithDynamoDbClient(client) .Build(); - bool actualValue = dbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); + var actualValue = dbMetastoreImpl.Store(DynamoDbMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord)); Assert.True(actualValue); } @@ -239,15 +236,15 @@ public void TestStoreWithDbErrorShouldThrowException() { Dispose(); Assert.Throws(() => - dynamoDbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord))); + _dynamoDbMetastoreImpl.Store(DynamoDbMetastoreHelper.ExistingTestKey, DateTimeOffset.Now, JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord))); } [Fact] public void TestStoreWithDuplicateShouldReturnFalse() { - DateTimeOffset now = DateTimeOffset.Now; - bool firstAttempt = dynamoDbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); - bool secondAttempt = dynamoDbMetastoreImpl.Store(DynamoMetastoreHelper.ExistingTestKey, now, JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord)); + var now = DateTimeOffset.Now; + var firstAttempt = _dynamoDbMetastoreImpl.Store(DynamoDbMetastoreHelper.ExistingTestKey, now, JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord)); + var secondAttempt = _dynamoDbMetastoreImpl.Store(DynamoDbMetastoreHelper.ExistingTestKey, now, JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord)); Assert.True(firstAttempt); Assert.False(secondAttempt); @@ -256,8 +253,8 @@ public void TestStoreWithDuplicateShouldReturnFalse() [Fact] public void TestBuilderPathWithEndPointConfiguration() { - DynamoDbMetastoreImpl dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .Build(); Assert.NotNull(dbMetastoreImpl); @@ -266,8 +263,8 @@ public void TestBuilderPathWithEndPointConfiguration() [Fact] public void TestBuilderPathWithRegion() { - Mock builder = new Mock(Region); - Table loadTable = (Table)new TableBuilder(amazonDynamoDbClient, "EncryptionKey") + var builder = new Mock(Region); + var loadTable = (Table)new TableBuilder(_amazonDynamoDbClient, "EncryptionKey") .AddHashKey(PartitionKey, DynamoDBEntryType.String) .AddRangeKey(SortKey, DynamoDBEntryType.Numeric) .Build(); @@ -275,7 +272,7 @@ public void TestBuilderPathWithRegion() builder.Setup(x => x.LoadTable(It.IsAny(), Region)) .Returns(loadTable); - DynamoDbMetastoreImpl dbMetastoreImpl = builder.Object + var dbMetastoreImpl = builder.Object .WithRegion(Region) .Build(); @@ -285,8 +282,8 @@ public void TestBuilderPathWithRegion() [Fact] public void TestBuilderPathWithKeySuffix() { - DynamoDbMetastoreImpl dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .WithKeySuffix() .Build(); @@ -297,8 +294,8 @@ public void TestBuilderPathWithKeySuffix() [Fact] public void TestBuilderPathWithoutKeySuffix() { - DynamoDbMetastoreImpl dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .Build(); Assert.NotNull(dbMetastoreImpl); @@ -308,8 +305,8 @@ public void TestBuilderPathWithoutKeySuffix() [Fact] public void TestBuilderPathWithCredentials() { - var dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .WithCredentials(new BasicAWSCredentials("dummykey", "dummy_secret")) .Build(); @@ -320,8 +317,8 @@ public void TestBuilderPathWithCredentials() public void TestBuilderPathWithInvalidCredentials() { var emptySecretKey = string.Empty; - Assert.ThrowsAny(() => NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + Assert.ThrowsAny(() => DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .WithCredentials(new BasicAWSCredentials("not-dummykey", emptySecretKey)) .Build()); } @@ -332,45 +329,45 @@ public async Task TestBuilderPathWithTableName() const string tempTableName = "DummyTable"; // Use AWS SDK to create client - AmazonDynamoDBConfig amazonDynamoDbConfig = new AmazonDynamoDBConfig + var amazonDynamoDbConfig = new AmazonDynamoDBConfig { - ServiceURL = serviceUrl, + ServiceURL = _serviceUrl, AuthenticationRegion = "us-west-2", }; - AmazonDynamoDBClient tempDynamoDbClient = new AmazonDynamoDBClient(amazonDynamoDbConfig); - await DynamoMetastoreHelper.CreateTableSchema(tempDynamoDbClient, tempTableName); + var tempDynamoDbClient = new AmazonDynamoDBClient(amazonDynamoDbConfig); + await DynamoDbMetastoreHelper.CreateTableSchema(tempDynamoDbClient, tempTableName); // Put the object in temp table - Table tempTable = (Table)new TableBuilder(tempDynamoDbClient, tempTableName) + var tempTable = (Table)new TableBuilder(tempDynamoDbClient, tempTableName) .AddHashKey(PartitionKey, DynamoDBEntryType.String) .AddRangeKey(SortKey, DynamoDBEntryType.Numeric) .Build(); - JObject jObject = JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord); - Document document = new Document + var jObject = JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord); + var document = new Document { - [PartitionKey] = DynamoMetastoreHelper.ExistingTestKey, - [SortKey] = created.ToUnixTimeSeconds(), + [PartitionKey] = DynamoDbMetastoreHelper.ExistingTestKey, + [SortKey] = _created.ToUnixTimeSeconds(), [AttributeKeyRecord] = Document.FromJson(jObject.ToString()), }; await tempTable.PutItemAsync(document, CancellationToken.None); // Create a metastore object using the withTableName step - DynamoDbMetastoreImpl dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, "us-west-2") + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, "us-west-2") .WithTableName(tempTableName) .Build(); - Option actualJsonObject = dbMetastoreImpl.Load(DynamoMetastoreHelper.ExistingTestKey, created); + var actualJsonObject = dbMetastoreImpl.Load(DynamoDbMetastoreHelper.ExistingTestKey, _created); // Verify that we were able to load and successfully decrypt the item from the metastore object created withTableName Assert.True(actualJsonObject.IsSome); - Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); + Assert.True(JToken.DeepEquals(JObject.FromObject(DynamoDbMetastoreHelper.ExistingKeyRecord), (JObject)actualJsonObject)); } [Fact] public void TestPrimaryBuilderPath() { - Mock builder = new Mock(Region); - Table loadTable = (Table)new TableBuilder(amazonDynamoDbClient, "EncryptionKey") + var builder = new Mock(Region); + var loadTable = (Table)new TableBuilder(_amazonDynamoDbClient, "EncryptionKey") .AddHashKey(PartitionKey, DynamoDBEntryType.String) .AddRangeKey(SortKey, DynamoDBEntryType.Numeric) .Build(); @@ -378,7 +375,7 @@ public void TestPrimaryBuilderPath() builder.Setup(x => x.LoadTable(It.IsAny(), Region)) .Returns(loadTable); - DynamoDbMetastoreImpl dbMetastoreImpl = builder.Object + var dbMetastoreImpl = builder.Object .Build(); Assert.NotNull(dbMetastoreImpl); @@ -389,8 +386,8 @@ public void TestBuilderPathWithLoggerEnabled() { var mockLogger = new Mock(); - var dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .WithLogger(mockLogger.Object) .Build(); @@ -400,8 +397,8 @@ public void TestBuilderPathWithLoggerEnabled() [Fact] public void TestBuilderPathWithLoggerDisabled() { - var dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .Build(); Assert.NotNull(dbMetastoreImpl); @@ -412,8 +409,8 @@ public void TestBuilderPathWithLoggerAndCredentials() { var mockLogger = new Mock(); - var dbMetastoreImpl = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var dbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .WithLogger(mockLogger.Object) .WithCredentials(new BasicAWSCredentials("dummykey", "dummy_secret")) .Build(); @@ -426,11 +423,11 @@ public void TestWithLoggerReturnsCorrectInterface() { var mockLogger = new Mock(); - var buildStep = NewBuilder(Region) - .WithEndPointConfiguration(serviceUrl, Region) + var buildStep = DynamoDbMetastoreImpl.NewBuilder(Region) + .WithEndPointConfiguration(_serviceUrl, Region) .WithLogger(mockLogger.Object); - Assert.IsAssignableFrom(buildStep); + Assert.IsAssignableFrom(buildStep); } } } diff --git a/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs b/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs index ed10803c9..79a257148 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Amazon.DynamoDBv2; @@ -12,7 +13,8 @@ namespace GoDaddy.Asherah.AppEncryption.Tests.Extensions.Aws.Metastore; -public class DynamoDbMetastoreTests : IClassFixture, IDisposable +[ExcludeFromCodeCoverage] +public class DynamoDbMetastoreTests : IClassFixture, IDisposable { private const string TestTableName = "TestKeysTable"; private const string TestRegion = "us-west-2"; @@ -22,32 +24,30 @@ public class DynamoDbMetastoreTests : IClassFixture, I private readonly DynamoDbMetastoreOptions _options; private readonly DateTimeOffset _created; - public DynamoDbMetastoreTests(DynamoDBContainerFixture dynamoDbContainerFixture) + public DynamoDbMetastoreTests(DynamoDbContainerFixture dynamoDbContainerFixture) { - string serviceUrl = dynamoDbContainerFixture.GetServiceUrl(); - AmazonDynamoDBConfig clientConfig = new AmazonDynamoDBConfig + var serviceUrl = dynamoDbContainerFixture.GetServiceUrl(); + AmazonDynamoDBConfig clientConfig = new() { ServiceURL = serviceUrl, AuthenticationRegion = TestRegion, }; _amazonDynamoDbClient = new AmazonDynamoDBClient(clientConfig); - DynamoMetastoreHelper.CreateTableSchema(_amazonDynamoDbClient, TestTableName).Wait(); + DynamoDbMetastoreHelper.CreateTableSchema(_amazonDynamoDbClient, TestTableName).Wait(); _options = new DynamoDbMetastoreOptions(TestTableName); _dynamoDbMetastore = new DynamoDbMetastore(_amazonDynamoDbClient, _options); // Pre-populate test data using helper and capture the created timestamp - _created = DynamoMetastoreHelper.PrePopulateTestDataUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion).Result; + _created = DynamoDbMetastoreHelper.PrePopulateTestDataUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion).Result; } public void Dispose() { try { - DeleteTableResponse deleteTableResponse = _amazonDynamoDbClient - .DeleteTableAsync(TestTableName) - .Result; + _ = _amazonDynamoDbClient.DeleteTableAsync(TestTableName).Result; } catch (AggregateException) { @@ -58,19 +58,19 @@ public void Dispose() private static void VerifyKeyRecordMatchesExpected(KeyRecord loadedKeyRecord) { // Test Key property - Assert.Equal((string)DynamoMetastoreHelper.ExistingKeyRecord["Key"], loadedKeyRecord.Key); + Assert.Equal((string)DynamoDbMetastoreHelper.ExistingKeyRecord["Key"], loadedKeyRecord.Key); // Test Created property - Assert.Equal(DateTimeOffset.FromUnixTimeSeconds((long)(int)DynamoMetastoreHelper.ExistingKeyRecord["Created"]), loadedKeyRecord.Created); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds((int)DynamoDbMetastoreHelper.ExistingKeyRecord["Created"]), loadedKeyRecord.Created); // Test Revoked property (should be null since not in test data) Assert.Null(loadedKeyRecord.Revoked); // Test ParentKeyMeta property Assert.NotNull(loadedKeyRecord.ParentKeyMeta); - var expectedParentKeyMeta = (Dictionary)DynamoMetastoreHelper.ExistingKeyRecord["ParentKeyMeta"]; + var expectedParentKeyMeta = (Dictionary)DynamoDbMetastoreHelper.ExistingKeyRecord["ParentKeyMeta"]; Assert.Equal((string)expectedParentKeyMeta["KeyId"], loadedKeyRecord.ParentKeyMeta.Id); - Assert.Equal(DateTimeOffset.FromUnixTimeSeconds((long)(int)expectedParentKeyMeta["Created"]), loadedKeyRecord.ParentKeyMeta.Created); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds((int)expectedParentKeyMeta["Created"]), loadedKeyRecord.ParentKeyMeta.Created); } private DynamoDbMetastore CreateMetastoreWithBrokenDynamoClient() @@ -102,7 +102,7 @@ private DynamoDbMetastore CreateMetastoreWithBrokenDynamoClientForStore() [Fact] public async Task TestLoadSuccess() { - var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadAsync(DynamoMetastoreHelper.ExistingTestKey, _created); + var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadAsync(DynamoDbMetastoreHelper.ExistingTestKey, _created); Assert.True(found); Assert.NotNull(loadedKeyRecord); @@ -121,7 +121,7 @@ public async Task TestLoadWithNoResultShouldReturnFalse() [Fact] public async Task TestLoadLatestWithSingleRecord() { - var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadLatestAsync(DynamoMetastoreHelper.ExistingTestKey); + var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadLatestAsync(DynamoDbMetastoreHelper.ExistingTestKey); Assert.True(found); Assert.NotNull(loadedKeyRecord); @@ -141,25 +141,25 @@ public async Task TestLoadLatestWithNoResultShouldReturnFalse() public async Task TestLoadLatestWithMultipleRecords() { // Create multiple records with different timestamps - DateTimeOffset createdMinusOneHour = _created.AddHours(-1); - DateTimeOffset createdPlusOneHour = _created.AddHours(1); - DateTimeOffset createdMinusOneDay = _created.AddDays(-1); - DateTimeOffset createdPlusOneDay = _created.AddDays(1); + var createdMinusOneHour = _created.AddHours(-1); + var createdPlusOneHour = _created.AddHours(1); + var createdMinusOneDay = _created.AddDays(-1); + var createdPlusOneDay = _created.AddDays(1); // Create test KeyRecord objects - var keyRecordMinusOneHour = new KeyRecord(createdMinusOneHour, "key_minus_one_hour", null, null); - var keyRecordPlusOneHour = new KeyRecord(createdPlusOneHour, "key_plus_one_hour", null, null); - var keyRecordMinusOneDay = new KeyRecord(createdMinusOneDay, "key_minus_one_day", null, null); - var keyRecordPlusOneDay = new KeyRecord(createdPlusOneDay, "key_plus_one_day", null, null); + var keyRecordMinusOneHour = new KeyRecord(createdMinusOneHour, "key_minus_one_hour", null); + var keyRecordPlusOneHour = new KeyRecord(createdPlusOneHour, "key_plus_one_hour", null); + var keyRecordMinusOneDay = new KeyRecord(createdMinusOneDay, "key_minus_one_day", null); + var keyRecordPlusOneDay = new KeyRecord(createdPlusOneDay, "key_plus_one_day", null); // Insert records using the old metastore (intentionally mixing up insertion order) - await DynamoMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoMetastoreHelper.ExistingTestKey, createdPlusOneHour, keyRecordPlusOneHour); - await DynamoMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoMetastoreHelper.ExistingTestKey, createdPlusOneDay, keyRecordPlusOneDay); - await DynamoMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoMetastoreHelper.ExistingTestKey, createdMinusOneHour, keyRecordMinusOneHour); - await DynamoMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoMetastoreHelper.ExistingTestKey, createdMinusOneDay, keyRecordMinusOneDay); + await DynamoDbMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoDbMetastoreHelper.ExistingTestKey, createdPlusOneHour, keyRecordPlusOneHour); + await DynamoDbMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoDbMetastoreHelper.ExistingTestKey, createdPlusOneDay, keyRecordPlusOneDay); + await DynamoDbMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoDbMetastoreHelper.ExistingTestKey, createdMinusOneHour, keyRecordMinusOneHour); + await DynamoDbMetastoreHelper.AddKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, DynamoDbMetastoreHelper.ExistingTestKey, createdMinusOneDay, keyRecordMinusOneDay); // Test that LoadLatest returns the record with the latest timestamp - var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadLatestAsync(DynamoMetastoreHelper.ExistingTestKey); + var (found, loadedKeyRecord) = await _dynamoDbMetastore.TryLoadLatestAsync(DynamoDbMetastoreHelper.ExistingTestKey); Assert.True(found); Assert.NotNull(loadedKeyRecord); @@ -174,7 +174,7 @@ public async Task TestLoadWithFailureShouldThrowException() var brokenMetastore = CreateMetastoreWithBrokenDynamoClient(); await Assert.ThrowsAsync( - () => brokenMetastore.TryLoadAsync(DynamoMetastoreHelper.ExistingTestKey, _created)); + () => brokenMetastore.TryLoadAsync(DynamoDbMetastoreHelper.ExistingTestKey, _created)); } [Fact] @@ -183,7 +183,7 @@ public async Task TestLoadLatestWithFailureShouldThrowException() var brokenMetastore = CreateMetastoreWithBrokenDynamoClient(); await Assert.ThrowsAsync( - () => brokenMetastore.TryLoadLatestAsync(DynamoMetastoreHelper.ExistingTestKey)); + () => brokenMetastore.TryLoadLatestAsync(DynamoDbMetastoreHelper.ExistingTestKey)); } [Fact] @@ -206,9 +206,9 @@ public void GetKeySuffixShouldReturnRegionEndpointName() public async Task TestStore(bool? revoked, bool hasParentKeyMeta) { // Arrange - string testKeyId = "test_store_key"; - DateTimeOffset testCreated = DateTimeOffset.Now; - KeyMeta parentKeyMeta = hasParentKeyMeta ? new KeyMeta("parent_key_id", DateTimeOffset.Now.AddDays(-1)) : null; + var testKeyId = "test_store_key"; + var testCreated = DateTimeOffset.Now; + var parentKeyMeta = hasParentKeyMeta ? new KeyMeta("parent_key_id", DateTimeOffset.Now.AddDays(-1)) : null; var testKeyRecord = new KeyRecord( testCreated, @@ -218,7 +218,7 @@ public async Task TestStore(bool? revoked, bool hasParentKeyMeta) ); // Act - bool storeResult = await _dynamoDbMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord); + var storeResult = await _dynamoDbMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord); // Assert Assert.True(storeResult); @@ -243,7 +243,7 @@ public async Task TestStore(bool? revoked, bool hasParentKeyMeta) } // Verify the stored record can also be loaded by the old metastore implementation - DynamoMetastoreHelper.VerifyKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, testKeyId, testKeyRecord); + DynamoDbMetastoreHelper.VerifyKeyRecordUsingOldMetastore(_amazonDynamoDbClient, TestTableName, TestRegion, testKeyId, testKeyRecord); } [Fact] @@ -251,9 +251,9 @@ public async Task TestStoreWithDbErrorShouldThrowException() { // Arrange var brokenMetastore = CreateMetastoreWithBrokenDynamoClientForStore(); - string testKeyId = "test_store_key"; - DateTimeOffset testCreated = DateTimeOffset.Now; - var testKeyRecord = new KeyRecord(testCreated, "test_encrypted_key_data", null, null); + var testKeyId = "test_store_key"; + var testCreated = DateTimeOffset.Now; + var testKeyRecord = new KeyRecord(testCreated, "test_encrypted_key_data", null); // Act & Assert await Assert.ThrowsAsync( @@ -264,13 +264,13 @@ await Assert.ThrowsAsync( public async Task TestStoreWithDuplicateShouldReturnFalse() { // Arrange - string testKeyId = "test_duplicate_key"; - DateTimeOffset testCreated = DateTimeOffset.Now; - var testKeyRecord = new KeyRecord(testCreated, "test_encrypted_key_data", null, null); + var testKeyId = "test_duplicate_key"; + var testCreated = DateTimeOffset.Now; + var testKeyRecord = new KeyRecord(testCreated, "test_encrypted_key_data", null); // Act - bool firstAttempt = await _dynamoDbMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord); - bool secondAttempt = await _dynamoDbMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord); + var firstAttempt = await _dynamoDbMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord); + var secondAttempt = await _dynamoDbMetastore.StoreAsync(testKeyId, testCreated, testKeyRecord); // Assert Assert.True(firstAttempt); diff --git a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDBContainerFixture.cs b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDBContainerFixture.cs index 86cde0625..91c4fe307 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDBContainerFixture.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDBContainerFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Threading.Tasks; using Testcontainers.DynamoDb; @@ -6,18 +7,19 @@ namespace GoDaddy.Asherah.AppEncryption.Tests.Fixtures; -public class DynamoDBContainerFixture : IAsyncLifetime +[ExcludeFromCodeCoverage] +public class DynamoDbContainerFixture : IAsyncLifetime { private readonly string _localServiceUrl; private readonly DynamoDbContainer _dynamoDbContainer; - public DynamoDBContainerFixture() + public DynamoDbContainerFixture() { var disableTestContainers = Convert.ToBoolean(Environment.GetEnvironmentVariable("DISABLE_TESTCONTAINERS"), CultureInfo.InvariantCulture); if (disableTestContainers) { - string hostname = Environment.GetEnvironmentVariable("DYNAMODB_HOSTNAME") ?? "localhost"; + var hostname = Environment.GetEnvironmentVariable("DYNAMODB_HOSTNAME") ?? "localhost"; _localServiceUrl = $"http://{hostname}:8000"; } else diff --git a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoMetastoreHelper.cs b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs similarity index 79% rename from csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoMetastoreHelper.cs rename to csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs index 15e06e452..7efd2584a 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoMetastoreHelper.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.DocumentModel; @@ -10,11 +11,11 @@ using GoDaddy.Asherah.AppEncryption.Util; using Newtonsoft.Json.Linq; using Xunit; -using static GoDaddy.Asherah.AppEncryption.Persistence.DynamoDbMetastoreImpl; namespace GoDaddy.Asherah.AppEncryption.Tests.Fixtures; -public static class DynamoMetastoreHelper +[ExcludeFromCodeCoverage] +public static class DynamoDbMetastoreHelper { public const string ExistingTestKey = "some_key"; public static readonly Dictionary ExistingKeyRecord = new Dictionary @@ -37,13 +38,13 @@ public static class DynamoMetastoreHelper private static Table CreateTableInstance(IAmazonDynamoDB client, string tableName, string region) { // Create the old DynamoDB implementation - DynamoDbMetastoreImpl dynamoDbMetastoreImpl = NewBuilder(region) + var dynamoDbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(region) .WithEndPointConfiguration(client.Config.ServiceURL, region) .WithTableName(tableName) .Build(); // Create the table instance - return (Table)new TableBuilder(client, dynamoDbMetastoreImpl.TableName) + return new TableBuilder(client, dynamoDbMetastoreImpl.TableName) .AddHashKey(PartitionKey, DynamoDBEntryType.String) .AddRangeKey(SortKey, DynamoDBEntryType.Numeric) .Build(); @@ -51,8 +52,8 @@ private static Table CreateTableInstance(IAmazonDynamoDB client, string tableNam private static async Task InsertDocumentAsync(Table table, string keyId, DateTimeOffset created, Dictionary keyRecordDict) { - JObject jObject = JObject.FromObject(keyRecordDict); - Document document = new Document + var jObject = JObject.FromObject(keyRecordDict); + var document = new Document { [PartitionKey] = keyId, [SortKey] = created.ToUnixTimeSeconds(), @@ -64,32 +65,32 @@ private static async Task InsertDocumentAsync(Table table, string keyId, DateTim public static async Task CreateTableSchema(AmazonDynamoDBClient client, string tableName) { - CreateTableRequest request = new CreateTableRequest + var request = new CreateTableRequest { TableName = tableName, - AttributeDefinitions = new List - { + AttributeDefinitions = + [ new AttributeDefinition(PartitionKey, ScalarAttributeType.S), - new AttributeDefinition(SortKey, ScalarAttributeType.N), - }, - KeySchema = new List - { + new AttributeDefinition(SortKey, ScalarAttributeType.N) + ], + KeySchema = + [ new KeySchemaElement(PartitionKey, KeyType.HASH), - new KeySchemaElement(SortKey, KeyType.RANGE), - }, + new KeySchemaElement(SortKey, KeyType.RANGE) + ], ProvisionedThroughput = new ProvisionedThroughput(1L, 1L), }; - CreateTableResponse createTableResponse = await client.CreateTableAsync(request); + await client.CreateTableAsync(request); } public static async Task PrePopulateTestDataUsingOldMetastore(IAmazonDynamoDB client, string tableName, string region) { - Table table = CreateTableInstance(client, tableName, region); + var table = CreateTableInstance(client, tableName, region); // Test data - string testKeyWithRegionSuffix = ExistingTestKey + "_" + region; - DateTimeOffset created = DateTimeOffset.Now.AddDays(-1); + var testKeyWithRegionSuffix = ExistingTestKey + "_" + region; + var created = DateTimeOffset.Now.AddDays(-1); // Pre-populate test data await InsertDocumentAsync(table, ExistingTestKey, created, ExistingKeyRecord); @@ -100,10 +101,10 @@ public static async Task PrePopulateTestDataUsingOldMetastore(IA public static async Task AddKeyRecordUsingOldMetastore(IAmazonDynamoDB client, string tableName, string region, string keyId, DateTimeOffset created, KeyRecord keyRecord) { - Table table = CreateTableInstance(client, tableName, region); + var table = CreateTableInstance(client, tableName, region); // Convert KeyRecord to Dictionary format - Dictionary keyRecordDict = new Dictionary + var keyRecordDict = new Dictionary { { "Key", keyRecord.Key }, { "Created", keyRecord.Created.ToUnixTimeSeconds() } @@ -135,7 +136,7 @@ public static void VerifyKeyRecordUsingOldMetastore(IAmazonDynamoDB client, stri MetricsUtil.SetMetricsInstance(AppMetrics.CreateDefaultBuilder().Build()); // Create the old DynamoDB implementation - DynamoDbMetastoreImpl dynamoDbMetastoreImpl = NewBuilder(region) + var dynamoDbMetastoreImpl = DynamoDbMetastoreImpl.NewBuilder(region) .WithEndPointConfiguration(client.Config.ServiceURL, region) .WithTableName(tableName) .Build(); @@ -148,14 +149,14 @@ public static void VerifyKeyRecordUsingOldMetastore(IAmazonDynamoDB client, stri var loadedKeyRecord = (JObject)loadedJsonObject; // Validate the properties - Assert.Equal(expectedKeyRecord.Key, loadedKeyRecord["Key"].ToString()); - Assert.Equal(expectedKeyRecord.Created.ToUnixTimeSeconds(), loadedKeyRecord["Created"].ToObject()); + Assert.Equal(expectedKeyRecord.Key, loadedKeyRecord["Key"]!.ToString()); + Assert.Equal(expectedKeyRecord.Created.ToUnixTimeSeconds(), loadedKeyRecord["Created"]!.ToObject()); // Validate Revoked if (expectedKeyRecord.Revoked.HasValue) { Assert.True(loadedKeyRecord.ContainsKey("Revoked")); - Assert.Equal(expectedKeyRecord.Revoked.Value, loadedKeyRecord["Revoked"].ToObject()); + Assert.Equal(expectedKeyRecord.Revoked.Value, loadedKeyRecord["Revoked"]!.ToObject()); } else { @@ -166,9 +167,9 @@ public static void VerifyKeyRecordUsingOldMetastore(IAmazonDynamoDB client, stri if (expectedKeyRecord.ParentKeyMeta != null) { Assert.True(loadedKeyRecord.ContainsKey("ParentKeyMeta")); - var parentKeyMeta = loadedKeyRecord["ParentKeyMeta"].ToObject(); - Assert.Equal(expectedKeyRecord.ParentKeyMeta.Id, parentKeyMeta["KeyId"].ToString()); - Assert.Equal(expectedKeyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds(), parentKeyMeta["Created"].ToObject()); + var parentKeyMeta = loadedKeyRecord["ParentKeyMeta"]!.ToObject(); + Assert.Equal(expectedKeyRecord.ParentKeyMeta.Id, parentKeyMeta["KeyId"]!.ToString()); + Assert.Equal(expectedKeyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds(), parentKeyMeta["Created"]!.ToObject()); } else { diff --git a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs index a6696888b..f66931232 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Data; using System.Linq; -using System.Runtime.CompilerServices; using System.Threading.Tasks; namespace GoDaddy.Asherah.AppEncryption.Metastore diff --git a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs index 1c874a4ff..4f49636b5 100644 --- a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs +++ b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs @@ -5,7 +5,6 @@ using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; using GoDaddy.Asherah.AppEncryption.Metastore; -using GoDaddy.Asherah.Crypto.Exceptions; namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore; @@ -17,9 +16,9 @@ namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore; /// Configuration options for the metastore. public class DynamoDbMetastore(IAmazonDynamoDB dynamoDbClient, DynamoDbMetastoreOptions options) : IKeyMetastore { - internal const string PartitionKey = "Id"; - internal const string SortKey = "Created"; - internal const string AttributeKeyRecord = "KeyRecord"; + private const string PartitionKey = "Id"; + private const string SortKey = "Created"; + private const string AttributeKeyRecord = "KeyRecord"; /// public async Task<(bool found, KeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) @@ -29,8 +28,8 @@ public class DynamoDbMetastore(IAmazonDynamoDB dynamoDbClient, DynamoDbMetastore TableName = options.KeyRecordTableName, Key = new Dictionary { - [PartitionKey] = new AttributeValue { S = keyId }, - [SortKey] = new AttributeValue { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + [PartitionKey] = new() { S = keyId }, + [SortKey] = new() { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } }, ProjectionExpression = AttributeKeyRecord, ConsistentRead = true @@ -56,7 +55,7 @@ public class DynamoDbMetastore(IAmazonDynamoDB dynamoDbClient, DynamoDbMetastore KeyConditionExpression = $"{PartitionKey} = :keyId", ExpressionAttributeValues = new Dictionary { - [":keyId"] = new AttributeValue { S = keyId } + [":keyId"] = new() { S = keyId } }, ProjectionExpression = AttributeKeyRecord, ScanIndexForward = false, // Sort descending (latest first) @@ -66,17 +65,19 @@ public class DynamoDbMetastore(IAmazonDynamoDB dynamoDbClient, DynamoDbMetastore var response = await dynamoDbClient.QueryAsync(request); - if (response.Items != null && response.Items.Count > 0) + if (response.Items is not { Count: > 0 }) { - var item = response.Items[0]; - if (item.TryGetValue(AttributeKeyRecord, out var keyRecordAttribute)) - { - var keyRecord = ConvertAttributeValueToKeyRecord(keyRecordAttribute); - return (true, keyRecord); - } + return (false, null); } - return (false, null); + var item = response.Items[0]; + if (!item.TryGetValue(AttributeKeyRecord, out var keyRecordAttribute)) + { + return (false, null); + } + + var keyRecord = ConvertAttributeValueToKeyRecord(keyRecordAttribute); + return (true, keyRecord); } /// @@ -86,8 +87,8 @@ public async Task StoreAsync(string keyId, DateTimeOffset created, KeyReco { var keyRecordMap = new Dictionary { - ["Key"] = new AttributeValue { S = keyRecord.Key }, - ["Created"] = new AttributeValue { N = keyRecord.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + ["Key"] = new() { S = keyRecord.Key }, + ["Created"] = new() { N = keyRecord.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } }; // Only add Revoked if it has a value @@ -103,8 +104,8 @@ public async Task StoreAsync(string keyId, DateTimeOffset created, KeyReco { M = new Dictionary { - ["KeyId"] = new AttributeValue { S = keyRecord.ParentKeyMeta.Id }, - ["Created"] = new AttributeValue { N = keyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + ["KeyId"] = new() { S = keyRecord.ParentKeyMeta.Id }, + ["Created"] = new() { N = keyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } } }; } @@ -113,8 +114,8 @@ public async Task StoreAsync(string keyId, DateTimeOffset created, KeyReco var item = new Dictionary { - [PartitionKey] = new AttributeValue { S = keyId }, - [SortKey] = new AttributeValue { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, + [PartitionKey] = new() { S = keyId }, + [SortKey] = new() { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, [AttributeKeyRecord] = keyRecordAttribute }; diff --git a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs index ad654e2e9..dad5be79e 100644 --- a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs +++ b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs @@ -1,5 +1,3 @@ -using System; - namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore; /// diff --git a/tartufo.toml b/tartufo.toml index 8818f77ee..a2d3aa8de 100644 --- a/tartufo.toml +++ b/tartufo.toml @@ -53,6 +53,7 @@ exclude-signatures = [ { 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 = "350ebba07491fe87c1abe10b84e1991237ddd8ee69f46304a921f7a6784d617e", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"}, + { signature = "51e487e817c8c7333b7537a685e5c8e4f4afd84f7f41ad9b82117a95f4e54e1a", 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 5f7cb7869a8c38e540d74990b928836b60acf4b6 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 6 Sep 2025 17:56:36 -0700 Subject: [PATCH 05/31] Code quality fixes --- .../AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs index 7efd2584a..c30d9788f 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs @@ -18,7 +18,7 @@ namespace GoDaddy.Asherah.AppEncryption.Tests.Fixtures; public static class DynamoDbMetastoreHelper { public const string ExistingTestKey = "some_key"; - public static readonly Dictionary ExistingKeyRecord = new Dictionary + public static readonly Dictionary ExistingKeyRecord = new() { { "ParentKeyMeta", new Dictionary @@ -27,7 +27,7 @@ public static class DynamoDbMetastoreHelper { "Created", 1541461380 }, } }, - { "Key", "mWT/x4RvIFVFE2BEYV1IB9FMM8sWN1sK6YN5bS2UyGR+9RSZVTvp/bcQ6PycW6kxYEqrpA+aV4u04jOr" }, + { "Key", "fake-key-data" }, { "Created", 1541461380 }, }; From c749995fe068e2a69062b48a6f9ad28b1d1d0e00 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sun, 7 Sep 2025 08:50:05 -0700 Subject: [PATCH 06/31] new envelopeencryption in progess --- .../Metastore/InMemoryKeyMetastoreImplTest.cs | 2 +- .../Aws/Metastore/DynamoDbMetastoreTests.cs | 13 +- .../Fixtures/DynamoDbMetastoreHelper.cs | 4 +- .../AppEncryption/Envelope/DataRowRecord.cs | 22 ++ .../Envelope/DataRowRecordKey.cs | 33 ++ .../Envelope/EnvelopeEncryption.cs | 136 +++++++++ .../AppEncryption/Metastore/IKeyMeta.cs | 20 ++ .../AppEncryption/Metastore/IKeyMetastore.cs | 6 +- .../AppEncryption/Metastore/IKeyRecord.cs | 31 ++ .../Metastore/InMemoryKeyMetastoreImpl.cs | 14 +- .../AppEncryption/Metastore/KeyMeta.cs | 31 +- .../AppEncryption/Metastore/KeyRecord.cs | 9 +- .../Serialization/InterfaceConverter.cs | 30 ++ .../UnixTimestampDateTimeOffsetConverter.cs | 32 ++ .../Extensions.Aws/Extensions.Aws.csproj | 3 +- .../Metastore/DynamoDbMetastore.cs | 286 +++++++++--------- .../Metastore/DynamoDbMetastoreOptions.cs | 20 +- 17 files changed, 506 insertions(+), 186 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption/Envelope/DataRowRecord.cs create mode 100644 csharp/AppEncryption/AppEncryption/Envelope/DataRowRecordKey.cs create mode 100644 csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs create mode 100644 csharp/AppEncryption/AppEncryption/Metastore/IKeyMeta.cs create mode 100644 csharp/AppEncryption/AppEncryption/Metastore/IKeyRecord.cs create mode 100644 csharp/AppEncryption/AppEncryption/Serialization/InterfaceConverter.cs create mode 100644 csharp/AppEncryption/AppEncryption/Serialization/UnixTimestampDateTimeOffsetConverter.cs diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs index 810e83ad7..130eb49cb 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs @@ -102,7 +102,7 @@ private async Task TestStoreWithIntermediateKeyRecord() { const string keyId = "ThisIsMyKey"; DateTimeOffset created = DateTimeOffset.UtcNow; - var parentKeyMeta = new KeyMeta("parentKey", created.AddDays(-1)); + var parentKeyMeta = new KeyMeta { KeyId = "parentKey", Created = created.AddDays(-1) }; var keyRecord = new KeyRecord(created, "test-key-data-parent", false, parentKeyMeta); bool success = await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); diff --git a/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs b/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs index 79a257148..eb0406ce3 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs @@ -36,7 +36,10 @@ public DynamoDbMetastoreTests(DynamoDbContainerFixture dynamoDbContainerFixture) DynamoDbMetastoreHelper.CreateTableSchema(_amazonDynamoDbClient, TestTableName).Wait(); - _options = new DynamoDbMetastoreOptions(TestTableName); + _options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = TestTableName + }; _dynamoDbMetastore = new DynamoDbMetastore(_amazonDynamoDbClient, _options); // Pre-populate test data using helper and capture the created timestamp @@ -55,7 +58,7 @@ public void Dispose() } } - private static void VerifyKeyRecordMatchesExpected(KeyRecord loadedKeyRecord) + private static void VerifyKeyRecordMatchesExpected(IKeyRecord loadedKeyRecord) { // Test Key property Assert.Equal((string)DynamoDbMetastoreHelper.ExistingKeyRecord["Key"], loadedKeyRecord.Key); @@ -69,7 +72,7 @@ private static void VerifyKeyRecordMatchesExpected(KeyRecord loadedKeyRecord) // Test ParentKeyMeta property Assert.NotNull(loadedKeyRecord.ParentKeyMeta); var expectedParentKeyMeta = (Dictionary)DynamoDbMetastoreHelper.ExistingKeyRecord["ParentKeyMeta"]; - Assert.Equal((string)expectedParentKeyMeta["KeyId"], loadedKeyRecord.ParentKeyMeta.Id); + Assert.Equal((string)expectedParentKeyMeta["KeyId"], loadedKeyRecord.ParentKeyMeta.KeyId); Assert.Equal(DateTimeOffset.FromUnixTimeSeconds((int)expectedParentKeyMeta["Created"]), loadedKeyRecord.ParentKeyMeta.Created); } @@ -208,7 +211,7 @@ public async Task TestStore(bool? revoked, bool hasParentKeyMeta) // Arrange var testKeyId = "test_store_key"; var testCreated = DateTimeOffset.Now; - var parentKeyMeta = hasParentKeyMeta ? new KeyMeta("parent_key_id", DateTimeOffset.Now.AddDays(-1)) : null; + var parentKeyMeta = hasParentKeyMeta ? new KeyMeta { KeyId = "parent_key_id", Created = DateTimeOffset.Now.AddDays(-1) } : null; var testKeyRecord = new KeyRecord( testCreated, @@ -234,7 +237,7 @@ public async Task TestStore(bool? revoked, bool hasParentKeyMeta) if (hasParentKeyMeta) { Assert.NotNull(loadedKeyRecord.ParentKeyMeta); - Assert.Equal(testKeyRecord.ParentKeyMeta.Id, loadedKeyRecord.ParentKeyMeta.Id); + Assert.Equal(testKeyRecord.ParentKeyMeta.KeyId, loadedKeyRecord.ParentKeyMeta.KeyId); Assert.Equal(testKeyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds(), loadedKeyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds()); } else diff --git a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs index c30d9788f..f657f94b1 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/Fixtures/DynamoDbMetastoreHelper.cs @@ -121,7 +121,7 @@ public static async Task AddKeyRecordUsingOldMetastore(IAmazonDynamoDB client, s { keyRecordDict["ParentKeyMeta"] = new Dictionary { - { "KeyId", keyRecord.ParentKeyMeta.Id }, + { "KeyId", keyRecord.ParentKeyMeta.KeyId }, { "Created", keyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds() } }; } @@ -168,7 +168,7 @@ public static void VerifyKeyRecordUsingOldMetastore(IAmazonDynamoDB client, stri { Assert.True(loadedKeyRecord.ContainsKey("ParentKeyMeta")); var parentKeyMeta = loadedKeyRecord["ParentKeyMeta"]!.ToObject(); - Assert.Equal(expectedKeyRecord.ParentKeyMeta.Id, parentKeyMeta["KeyId"]!.ToString()); + Assert.Equal(expectedKeyRecord.ParentKeyMeta.KeyId, parentKeyMeta["KeyId"]!.ToString()); Assert.Equal(expectedKeyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds(), parentKeyMeta["Created"]!.ToObject()); } else diff --git a/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecord.cs b/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecord.cs new file mode 100644 index 000000000..f7e37aa15 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecord.cs @@ -0,0 +1,22 @@ +using System; +using GoDaddy.Asherah.AppEncryption.Metastore; + +namespace GoDaddy.Asherah.AppEncryption.Envelope +{ + /// + /// Internal model representing a DataRowRecord with strongly-typed structure. + /// This replaces the generic byte[] approach with a concrete model that matches the JSON structure. + /// + internal class DataRowRecord + { + /// + /// Gets or sets the key portion containing the encrypted data row key and metadata. + /// + public IKeyRecord Key { get; set; } + + /// + /// Gets or sets the base64-encoded encrypted data byte array. + /// + public string Data { get; set; } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecordKey.cs b/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecordKey.cs new file mode 100644 index 000000000..72fcc357d --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecordKey.cs @@ -0,0 +1,33 @@ +using System; +using GoDaddy.Asherah.AppEncryption.Metastore; + +namespace GoDaddy.Asherah.AppEncryption.Envelope +{ + /// + /// Internal model representing the key portion of a DataRowRecord. + /// This is specifically for data row keys and does not include revocation status. + /// + internal class DataRowRecordKey : IKeyRecord + { + /// + /// Gets or sets the creation timestamp of the encrypted key. + /// + public DateTimeOffset Created { get; set; } + + /// + /// Gets or sets the base64-encoded encrypted data row key byte array. + /// + public string Key { get; set; } + + /// + /// Gets or sets the revocation status of the encrypted key. + /// Data row keys are never revoked, so this always returns null. + /// + public bool? Revoked => null; + + /// + /// Gets or sets the metadata for the parent key, if any. + /// + public IKeyMeta ParentKeyMeta { get; set; } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs new file mode 100644 index 000000000..89cce5c4b --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs @@ -0,0 +1,136 @@ +using System; +using System.Text.Json; +using GoDaddy.Asherah.AppEncryption.Exceptions; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.AppEncryption.Serialization; +using GoDaddy.Asherah.Crypto.Envelope; +using GoDaddy.Asherah.Crypto.Keys; + +using MetastoreKeyMeta = GoDaddy.Asherah.AppEncryption.Metastore.KeyMeta; + +namespace GoDaddy.Asherah.AppEncryption.Envelope +{ + /// + /// Internal implementation of that uses byte[] as the Data Row Record format. + /// This class will eventually replace the current EnvelopeEncryptionBytesImpl to support the new IKeyMetastore integration. + /// + internal sealed class EnvelopeEncryption : IEnvelopeEncryption + { + private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { + new InterfaceConverter(), + new InterfaceConverter() + } + }; + + private readonly Partition _partition; + private readonly AeadEnvelopeCrypto _crypto; + + /// + /// Initializes a new instance of the class. + /// + /// The partition for this envelope encryption instance. + /// The crypto implementation for envelope operations. + public EnvelopeEncryption(Partition partition, AeadEnvelopeCrypto crypto) + { + _partition = partition; + _crypto = crypto; + } + + /// + public byte[] DecryptDataRowRecord(byte[] dataRowRecord) + { + // Step 1: Deserialize the byte array into a strongly-typed DataRowRecord + DataRowRecord dataRowRecordModel = DeserializeDataRowRecord(dataRowRecord); + + // Step 2: Validate that we have ParentKeyMeta + if (dataRowRecordModel.Key?.ParentKeyMeta == null) + { + throw new MetadataMissingException("Could not find parentKeyMeta {IK} for dataRowKey"); + } + + // Step 3: Validate intermediate key ID against partition + if (!_partition.IsValidIntermediateKeyId(dataRowRecordModel.Key.ParentKeyMeta.KeyId)) + { + throw new MetadataMissingException("Could not find parentKeyMeta {IK} for dataRowKey"); + } + + // Step 4: Extract encrypted payload and key from base64 strings + byte[] payloadEncrypted = Convert.FromBase64String(dataRowRecordModel.Data); + byte[] encryptedKey = Convert.FromBase64String(dataRowRecordModel.Key.Key); + + // Step 5: Decrypt using intermediate key + byte[] decryptedPayload = WithIntermediateKeyForRead( + dataRowRecordModel.Key.ParentKeyMeta, + intermediateCryptoKey => + _crypto.EnvelopeDecrypt( + payloadEncrypted, + encryptedKey, + dataRowRecordModel.Key.Created, + intermediateCryptoKey)); + + return decryptedPayload; + } + + /// + public byte[] EncryptPayload(byte[] payload) + { + throw new NotImplementedException("Implementation will be added later"); + } + + /// + /// Executes a function with the intermediate key for read operations. + /// + /// intermediate key meta used previously to write a DRR. + /// the function to call using the decrypted intermediate key. + /// The result of the function execution. + private byte[] WithIntermediateKeyForRead( + IKeyMeta intermediateKeyMeta, Func functionWithIntermediateKey) + { + throw new NotImplementedException("Implementation will be added later"); + } + + /// + public void Dispose() + { + // Implementation will be added later + } + + /// + /// Deserializes a byte array containing UTF-8 JSON into a strongly-typed DataRowRecord. + /// + /// + /// The UTF-8 encoded JSON bytes representing the DataRowRecord. + /// A deserialized DataRowRecord object. + private static DataRowRecord DeserializeDataRowRecord(byte[] dataRowRecordBytes) + { + if (dataRowRecordBytes == null || dataRowRecordBytes.Length == 0) + { + throw new ArgumentException("DataRowRecord bytes cannot be null or empty", nameof(dataRowRecordBytes)); + } + + DataRowRecord result; + try + { + result = JsonSerializer.Deserialize(dataRowRecordBytes, JsonOptions); + } + catch (JsonException ex) + { + throw new ArgumentException("Invalid JSON format in DataRowRecord bytes", nameof(dataRowRecordBytes), ex); + } + catch (Exception ex) + { + throw new ArgumentException("Failed to deserialize DataRowRecord", nameof(dataRowRecordBytes), ex); + } + + if (result == null) + { + throw new ArgumentException("Deserialized DataRowRecord cannot be null", nameof(dataRowRecordBytes)); + } + + return result; + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Metastore/IKeyMeta.cs b/csharp/AppEncryption/AppEncryption/Metastore/IKeyMeta.cs new file mode 100644 index 000000000..ec8d9dde8 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Metastore/IKeyMeta.cs @@ -0,0 +1,20 @@ +using System; + +namespace GoDaddy.Asherah.AppEncryption.Metastore +{ + /// + /// Represents a readonly interface for metadata for a parent key. + /// + public interface IKeyMeta + { + /// + /// Gets the key identifier. + /// + string KeyId { get; } + + /// + /// Gets the creation time of the key. + /// + DateTimeOffset Created { get; } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs b/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs index 6768edfa0..1382693ee 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/IKeyMetastore.cs @@ -16,7 +16,7 @@ public interface IKeyMetastore /// The keyId to lookup. /// The created time to lookup. /// A tuple containing a boolean indicating if the key record was found and the key record if found. - Task<(bool found, KeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created); + Task<(bool found, IKeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created); /// /// Attempts to load the latest key record associated with the keyId. @@ -24,7 +24,7 @@ public interface IKeyMetastore /// /// The keyId to lookup. /// A tuple containing a boolean indicating if a key record was found and the latest key record if found. - Task<(bool found, KeyRecord keyRecord)> TryLoadLatestAsync(string keyId); + Task<(bool found, IKeyRecord keyRecord)> TryLoadLatestAsync(string keyId); /// /// Stores the key record using the specified keyId and created time. @@ -35,7 +35,7 @@ public interface IKeyMetastore /// The key record to store. /// True if the store succeeded, false if the store failed for a known condition e.g., trying to save /// a duplicate value should return false, not throw an exception. - Task StoreAsync(string keyId, DateTimeOffset created, KeyRecord keyRecord); + Task StoreAsync(string keyId, DateTimeOffset created, IKeyRecord keyRecord); /// /// Returns the key suffix or "" if key suffix option is disabled. diff --git a/csharp/AppEncryption/AppEncryption/Metastore/IKeyRecord.cs b/csharp/AppEncryption/AppEncryption/Metastore/IKeyRecord.cs new file mode 100644 index 000000000..3f5baf383 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Metastore/IKeyRecord.cs @@ -0,0 +1,31 @@ +using System; + +namespace GoDaddy.Asherah.AppEncryption.Metastore +{ + /// + /// Represents a readonly interface for a key record with basic properties for encrypted keys. + /// System KeyRecords will not have a ParentKeyMeta, while Intermediate KeyRecords will have a ParentKeyMeta. + /// + public interface IKeyRecord + { + /// + /// Gets the creation time of the encrypted key. + /// + DateTimeOffset Created { get; } + + /// + /// Gets the encoded/encrypted key data as a string. + /// + string Key { get; } + + /// + /// Gets the revocation status of the encrypted key. + /// + bool? Revoked { get; } + + /// + /// Gets the metadata for the parent key, if any. + /// + IKeyMeta ParentKeyMeta { get; } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs index f66931232..deebcd7a4 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs @@ -34,7 +34,7 @@ public InMemoryKeyMetastoreImpl() } /// - public Task<(bool found, KeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) + public Task<(bool found, IKeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) { lock (dataTable) { @@ -44,16 +44,16 @@ public InMemoryKeyMetastoreImpl() .ToList(); if (dataRows.Count == 0) { - return Task.FromResult((false, (KeyRecord)null)); + return Task.FromResult((false, (IKeyRecord)null)); } - var keyRecord = (KeyRecord)dataRows.Single()["keyRecord"]; + var keyRecord = (IKeyRecord)dataRows.Single()["keyRecord"]; return Task.FromResult((true, keyRecord)); } } /// - public Task<(bool found, KeyRecord keyRecord)> TryLoadLatestAsync(string keyId) + public Task<(bool found, IKeyRecord keyRecord)> TryLoadLatestAsync(string keyId) { lock (dataTable) { @@ -65,16 +65,16 @@ public InMemoryKeyMetastoreImpl() // Need to check if empty as Last will throw an exception instead of returning null if (dataRows.Count == 0) { - return Task.FromResult((false, (KeyRecord)null)); + return Task.FromResult((false, (IKeyRecord)null)); } - var keyRecord = (KeyRecord)dataRows.Last()["keyRecord"]; + var keyRecord = (IKeyRecord)dataRows.Last()["keyRecord"]; return Task.FromResult((true, keyRecord)); } } /// - public Task StoreAsync(string keyId, DateTimeOffset created, KeyRecord keyRecord) + public Task StoreAsync(string keyId, DateTimeOffset created, IKeyRecord keyRecord) { lock (dataTable) { diff --git a/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs b/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs index b14ce975d..e050a9a68 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs @@ -1,33 +1,24 @@ using System; +using System.Text.Json.Serialization; +using GoDaddy.Asherah.AppEncryption.Serialization; namespace GoDaddy.Asherah.AppEncryption.Metastore { /// /// Represents metadata for a parent key. /// - public class KeyMeta + public class KeyMeta : IKeyMeta { /// - /// Initializes a new instance of the class. + /// Gets or sets the key identifier. /// - /// - /// The key identifier. - /// The creation time of the key. - public KeyMeta(string id, DateTimeOffset created) - { - Id = id ?? throw new ArgumentNullException(nameof(id)); - Created = created; - } - - /// - /// Gets the key identifier. - /// - public string Id { get; } + public string KeyId { get; set; } /// - /// Gets the creation time of the key. + /// Gets or sets the creation time of the key. /// - public DateTimeOffset Created { get; } + [JsonConverter(typeof(UnixTimestampDateTimeOffsetConverter))] + public DateTimeOffset Created { get; set; } /// public override bool Equals(object obj) @@ -48,19 +39,19 @@ public override bool Equals(object obj) return false; } - return Id.Equals(other.Id, StringComparison.Ordinal) && Created.Equals(other.Created); + return KeyId.Equals(other.KeyId, StringComparison.Ordinal) && Created.Equals(other.Created); } /// public override int GetHashCode() { - return (Id, Created).GetHashCode(); + return (KeyId, Created).GetHashCode(); } /// public override string ToString() { - return "KeyMeta [Id=" + Id + ", Created=" + Created + "]"; + return "KeyMeta [KeyId=" + KeyId + ", Created=" + Created + "]"; } } } diff --git a/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs b/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs index a34cac15e..14c9af964 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs @@ -1,4 +1,6 @@ using System; +using System.Text.Json.Serialization; +using GoDaddy.Asherah.AppEncryption.Serialization; namespace GoDaddy.Asherah.AppEncryption.Metastore { @@ -6,7 +8,7 @@ namespace GoDaddy.Asherah.AppEncryption.Metastore /// Represents a key record with basic properties for encrypted keys. /// System KeyRecords will not have a ParentKeyMeta, while Intermediate KeyRecords will have a ParentKeyMeta. /// - public class KeyRecord + public class KeyRecord : IKeyRecord { /// /// Initializes a new instance of the class. @@ -17,7 +19,7 @@ public class KeyRecord /// The encoded/encrypted key data as a string. /// The revocation status of the encrypted key. /// The metadata for the parent key, if any. Defaults to null for system keys. - public KeyRecord(DateTimeOffset created, string key, bool? revoked, KeyMeta parentKeyMeta = null) + public KeyRecord(DateTimeOffset created, string key, bool? revoked, IKeyMeta parentKeyMeta = null) { Created = created; Key = key ?? throw new ArgumentNullException(nameof(key)); @@ -28,6 +30,7 @@ public KeyRecord(DateTimeOffset created, string key, bool? revoked, KeyMeta pare /// /// Gets the creation time of the encrypted key. /// + [JsonConverter(typeof(UnixTimestampDateTimeOffsetConverter))] public DateTimeOffset Created { get; } /// @@ -43,7 +46,7 @@ public KeyRecord(DateTimeOffset created, string key, bool? revoked, KeyMeta pare /// /// Gets the metadata for the parent key, if any. /// - public KeyMeta ParentKeyMeta { get; } + public IKeyMeta ParentKeyMeta { get; } } diff --git a/csharp/AppEncryption/AppEncryption/Serialization/InterfaceConverter.cs b/csharp/AppEncryption/AppEncryption/Serialization/InterfaceConverter.cs new file mode 100644 index 000000000..b262baa6d --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Serialization/InterfaceConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GoDaddy.Asherah.AppEncryption.Serialization +{ + /// + /// Generic JSON converter that handles serialization/deserialization between concrete types and their interfaces. + /// This allows JSON deserialization to work with interface types by specifying the concrete implementation. + /// + /// + /// The concrete type that implements the interface. + /// The interface type. + internal class InterfaceConverter : JsonConverter + where TConcrete : class, TInterface + { + /// + public override TInterface Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return JsonSerializer.Deserialize(ref reader, options); + } + + /// + public override void Write(Utf8JsonWriter writer, TInterface value, JsonSerializerOptions options) + { + // Write implementation can be added if needed for serialization + throw new NotImplementedException("Write operation not implemented"); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Serialization/UnixTimestampDateTimeOffsetConverter.cs b/csharp/AppEncryption/AppEncryption/Serialization/UnixTimestampDateTimeOffsetConverter.cs new file mode 100644 index 000000000..4455456c6 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Serialization/UnixTimestampDateTimeOffsetConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace GoDaddy.Asherah.AppEncryption.Serialization +{ + /// + /// JSON converter that converts Unix timestamp (long) to DateTimeOffset and vice versa. + /// This handles the conversion between Unix seconds since epoch and DateTimeOffset. + /// + internal class UnixTimestampDateTimeOffsetConverter : JsonConverter + { + /// + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.Number) + { + throw new JsonException($"Unexpected token type: {reader.TokenType}. Expected Number."); + } + + long unixTimestamp = reader.GetInt64(); + return DateTimeOffset.FromUnixTimeSeconds(unixTimestamp); + } + + /// + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + long unixTimestamp = value.ToUnixTimeSeconds(); + writer.WriteNumberValue(unixTimestamp); + } + } +} diff --git a/csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj b/csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj index 1adea69b7..22a341e08 100644 --- a/csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj +++ b/csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj @@ -5,8 +5,7 @@ GoDaddy GoDaddy AWS extensions for Application level envelope encryption SDK for C# - net8.0;net9.0 - latest + net8.0;net9.0;netstandard2.0 GoDaddy.Asherah.AppEncryption.Extensions.Aws diff --git a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs index 4f49636b5..6d4b18abe 100644 --- a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs +++ b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs @@ -6,183 +6,197 @@ using Amazon.DynamoDBv2.Model; using GoDaddy.Asherah.AppEncryption.Metastore; -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore; - -/// -/// Provides an AWS DynamoDB based implementation of to store and retrieve system keys -/// and intermediate keys as values. -/// -/// The AWS DynamoDB client to use for operations. -/// Configuration options for the metastore. -public class DynamoDbMetastore(IAmazonDynamoDB dynamoDbClient, DynamoDbMetastoreOptions options) : IKeyMetastore +namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore { - private const string PartitionKey = "Id"; - private const string SortKey = "Created"; - private const string AttributeKeyRecord = "KeyRecord"; - - /// - public async Task<(bool found, KeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) + /// + /// Provides an AWS DynamoDB based implementation of to store and retrieve system keys + /// and intermediate keys as values. + /// + public class DynamoDbMetastore : IKeyMetastore { - var request = new GetItemRequest - { - TableName = options.KeyRecordTableName, - Key = new Dictionary - { - [PartitionKey] = new() { S = keyId }, - [SortKey] = new() { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } - }, - ProjectionExpression = AttributeKeyRecord, - ConsistentRead = true - }; - - var response = await dynamoDbClient.GetItemAsync(request); - - if (response.Item != null && response.Item.TryGetValue(AttributeKeyRecord, out var keyRecordAttribute)) + private readonly IAmazonDynamoDB _dynamoDbClient; + private readonly DynamoDbMetastoreOptions _options; + + /// + /// Provides an AWS DynamoDB based implementation of to store and retrieve system keys + /// and intermediate keys as values. + /// + /// The AWS DynamoDB client to use for operations. + /// Configuration options for the metastore. + public DynamoDbMetastore(IAmazonDynamoDB dynamoDbClient, DynamoDbMetastoreOptions options) { - var keyRecord = ConvertAttributeValueToKeyRecord(keyRecordAttribute); - return (true, keyRecord); + _dynamoDbClient = dynamoDbClient; + _options = options; } - return (false, null); - } + private const string PartitionKey = "Id"; + private const string SortKey = "Created"; + private const string AttributeKeyRecord = "KeyRecord"; - /// - public async Task<(bool found, KeyRecord keyRecord)> TryLoadLatestAsync(string keyId) - { - var request = new QueryRequest + /// + public async Task<(bool found, IKeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) { - TableName = options.KeyRecordTableName, - KeyConditionExpression = $"{PartitionKey} = :keyId", - ExpressionAttributeValues = new Dictionary + var request = new GetItemRequest { - [":keyId"] = new() { S = keyId } - }, - ProjectionExpression = AttributeKeyRecord, - ScanIndexForward = false, // Sort descending (latest first) - Limit = 1, // Only get the latest item - ConsistentRead = true - }; + TableName = _options.KeyRecordTableName, + Key = new Dictionary + { + [PartitionKey] = new AttributeValue { S = keyId }, + [SortKey] = new AttributeValue { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + }, + ProjectionExpression = AttributeKeyRecord, + ConsistentRead = true + }; - var response = await dynamoDbClient.QueryAsync(request); + var response = await _dynamoDbClient.GetItemAsync(request); - if (response.Items is not { Count: > 0 }) - { - return (false, null); - } + if (response.Item != null && response.Item.TryGetValue(AttributeKeyRecord, out var keyRecordAttribute)) + { + var keyRecord = ConvertAttributeValueToKeyRecord(keyRecordAttribute); + return (true, keyRecord); + } - var item = response.Items[0]; - if (!item.TryGetValue(AttributeKeyRecord, out var keyRecordAttribute)) - { return (false, null); } - var keyRecord = ConvertAttributeValueToKeyRecord(keyRecordAttribute); - return (true, keyRecord); - } - - /// - public async Task StoreAsync(string keyId, DateTimeOffset created, KeyRecord keyRecord) - { - try + /// + public async Task<(bool found, IKeyRecord keyRecord)> TryLoadLatestAsync(string keyId) { - var keyRecordMap = new Dictionary + var request = new QueryRequest { - ["Key"] = new() { S = keyRecord.Key }, - ["Created"] = new() { N = keyRecord.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + TableName = _options.KeyRecordTableName, + KeyConditionExpression = $"{PartitionKey} = :keyId", + ExpressionAttributeValues = new Dictionary + { + [":keyId"] = new AttributeValue { S = keyId } + }, + ProjectionExpression = AttributeKeyRecord, + ScanIndexForward = false, // Sort descending (latest first) + Limit = 1, // Only get the latest item + ConsistentRead = true }; - // Only add Revoked if it has a value - if (keyRecord.Revoked.HasValue) + var response = await _dynamoDbClient.QueryAsync(request); + + if (response.Items == null || response.Items.Count == 0) { - keyRecordMap["Revoked"] = new AttributeValue { BOOL = keyRecord.Revoked.Value }; + return (false, (IKeyRecord)null); } - // Only add ParentKeyMeta if it exists - if (keyRecord.ParentKeyMeta != null) + var item = response.Items[0]; + if (!item.TryGetValue(AttributeKeyRecord, out var keyRecordAttribute)) { - keyRecordMap["ParentKeyMeta"] = new AttributeValue - { - M = new Dictionary - { - ["KeyId"] = new() { S = keyRecord.ParentKeyMeta.Id }, - ["Created"] = new() { N = keyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } - } - }; + return (false, (IKeyRecord)null); } - var keyRecordAttribute = new AttributeValue { M = keyRecordMap }; + var keyRecord = ConvertAttributeValueToKeyRecord(keyRecordAttribute); + return (true, keyRecord); + } - var item = new Dictionary + /// + public async Task StoreAsync(string keyId, DateTimeOffset created, IKeyRecord keyRecord) + { + try { - [PartitionKey] = new() { S = keyId }, - [SortKey] = new() { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, - [AttributeKeyRecord] = keyRecordAttribute - }; + var keyRecordMap = new Dictionary + { + ["Key"] = new AttributeValue { S = keyRecord.Key }, + ["Created"] = new AttributeValue { N = keyRecord.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + }; - var request = new PutItemRequest - { - TableName = options.KeyRecordTableName, - Item = item, - ConditionExpression = $"attribute_not_exists({PartitionKey})" - }; + // Only add Revoked if it has a value + if (keyRecord.Revoked.HasValue) + { + keyRecordMap["Revoked"] = new AttributeValue { BOOL = keyRecord.Revoked.Value }; + } - await dynamoDbClient.PutItemAsync(request); - return true; - } - catch (ConditionalCheckFailedException) - { - return false; - } - } + // Only add ParentKeyMeta if it exists + if (keyRecord.ParentKeyMeta != null) + { + keyRecordMap["ParentKeyMeta"] = new AttributeValue + { + M = new Dictionary + { + ["KeyId"] = new AttributeValue { S = keyRecord.ParentKeyMeta.KeyId }, + ["Created"] = new AttributeValue { N = keyRecord.ParentKeyMeta.Created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) } + } + }; + } - /// - public string GetKeySuffix() - { - return dynamoDbClient.Config.RegionEndpoint?.SystemName; - } + var keyRecordAttribute = new AttributeValue { M = keyRecordMap }; - private static KeyRecord ConvertAttributeValueToKeyRecord(AttributeValue keyRecordAttribute) - { - if (keyRecordAttribute.M == null) - { - throw new ArgumentException("KeyRecord attribute must be a Map", nameof(keyRecordAttribute)); - } + var item = new Dictionary + { + [PartitionKey] = new AttributeValue { S = keyId }, + [SortKey] = new AttributeValue { N = created.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture) }, + [AttributeKeyRecord] = keyRecordAttribute + }; - var map = keyRecordAttribute.M; + var request = new PutItemRequest + { + TableName = _options.KeyRecordTableName, + Item = item, + ConditionExpression = $"attribute_not_exists({PartitionKey})" + }; - if (!map.TryGetValue("Key", out var keyAttr) || keyAttr.S == null) - { - throw new ArgumentException("KeyRecord must contain Key field", nameof(keyRecordAttribute)); + await _dynamoDbClient.PutItemAsync(request); + return true; + } + catch (ConditionalCheckFailedException) + { + return false; + } } - var keyString = keyAttr.S; - // Extract Created (Unix timestamp) - if (!map.TryGetValue("Created", out var createdAttr) || createdAttr.N == null) + /// + public string GetKeySuffix() { - throw new ArgumentException("KeyRecord must contain Created field", nameof(keyRecordAttribute)); + return _dynamoDbClient.Config.RegionEndpoint?.SystemName; } - var created = DateTimeOffset.FromUnixTimeSeconds(long.Parse(createdAttr.N, CultureInfo.InvariantCulture)); - // Extract Revoked (optional boolean) - bool? revoked = null; - if (map.TryGetValue("Revoked", out var revokedAttr) && revokedAttr.BOOL.HasValue) + private static KeyRecord ConvertAttributeValueToKeyRecord(AttributeValue keyRecordAttribute) { - revoked = revokedAttr.BOOL.Value; - } + if (keyRecordAttribute.M == null) + { + throw new ArgumentException("KeyRecord attribute must be a Map", nameof(keyRecordAttribute)); + } - // Extract ParentKeyMeta (optional map) - KeyMeta parentKeyMeta = null; - if (map.TryGetValue("ParentKeyMeta", out var parentMetaAttr) && parentMetaAttr.M != null) - { - var parentMetaMap = parentMetaAttr.M; - if (parentMetaMap.TryGetValue("KeyId", out var parentKeyIdAttr) && parentMetaMap.TryGetValue("Created", out var parentCreatedAttr)) + var map = keyRecordAttribute.M; + + if (!map.TryGetValue("Key", out var keyAttr) || keyAttr.S == null) { - var parentKeyId = parentKeyIdAttr.S; - var parentCreated = DateTimeOffset.FromUnixTimeSeconds(long.Parse(parentCreatedAttr.N, CultureInfo.InvariantCulture)); - parentKeyMeta = new KeyMeta(parentKeyId, parentCreated); + throw new ArgumentException("KeyRecord must contain Key field", nameof(keyRecordAttribute)); } - } + var keyString = keyAttr.S; + + // Extract Created (Unix timestamp) + if (!map.TryGetValue("Created", out var createdAttr) || createdAttr.N == null) + { + throw new ArgumentException("KeyRecord must contain Created field", nameof(keyRecordAttribute)); + } + var created = DateTimeOffset.FromUnixTimeSeconds(long.Parse(createdAttr.N, CultureInfo.InvariantCulture)); - return new KeyRecord(created, keyString, revoked, parentKeyMeta); + // Extract Revoked (optional boolean) + bool? revoked = null; + if (map.TryGetValue("Revoked", out var revokedAttr) && revokedAttr.BOOL.HasValue) + { + revoked = revokedAttr.BOOL.Value; + } + + // Extract ParentKeyMeta (optional map) + KeyMeta parentKeyMeta = null; + if (map.TryGetValue("ParentKeyMeta", out var parentMetaAttr) && parentMetaAttr.M != null) + { + var parentMetaMap = parentMetaAttr.M; + if (parentMetaMap.TryGetValue("KeyId", out var parentKeyIdAttr) && parentMetaMap.TryGetValue("Created", out var parentCreatedAttr)) + { + var parentKeyId = parentKeyIdAttr.S; + var parentCreated = DateTimeOffset.FromUnixTimeSeconds(long.Parse(parentCreatedAttr.N, CultureInfo.InvariantCulture)); + parentKeyMeta = new KeyMeta { KeyId = parentKeyId, Created = parentCreated }; + } + } + + return new KeyRecord(created, keyString, revoked, parentKeyMeta); + } } } diff --git a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs index dad5be79e..00e92deda 100644 --- a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs +++ b/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs @@ -1,7 +1,13 @@ -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore; - -/// -/// Configuration options for DynamoDbMetastore. -/// -/// The name of the DynamoDB table to store key records. -public record DynamoDbMetastoreOptions(string KeyRecordTableName); +namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore +{ + /// + /// Configuration options for DynamoDbMetastore. + /// + public class DynamoDbMetastoreOptions + { + /// + /// The table name for the KeyRecord storage + /// + public string KeyRecordTableName { get; set; } = "KeyRecord"; + }; +} From 89d8549de183cd65b4c20602a161b2b6090e5751 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sun, 7 Sep 2025 17:21:15 -0700 Subject: [PATCH 07/31] Implementing the Decrypt methods --- .../Envelope/EnvelopeEncryption.cs | 268 +++++++++++++++++- .../Envelope/EnvelopeEncryptionBytesImpl.cs | 13 + .../Envelope/EnvelopeEncryptionJsonImpl.cs | 13 + .../Envelope/IEnvelopeEncryption.cs | 17 ++ csharp/AppEncryption/AppEncryption/Session.cs | 19 ++ .../AppEncryption/SessionBytesImpl.cs | 13 + .../AppEncryption/SessionFactory.cs | 11 + .../AppEncryption/SessionJsonImpl.cs | 13 + 8 files changed, 356 insertions(+), 11 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs index 89cce5c4b..350779882 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs @@ -1,10 +1,15 @@ using System; using System.Text.Json; +using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Exceptions; using GoDaddy.Asherah.AppEncryption.Metastore; using GoDaddy.Asherah.AppEncryption.Serialization; +using GoDaddy.Asherah.AppEncryption.Kms; using GoDaddy.Asherah.Crypto.Envelope; using GoDaddy.Asherah.Crypto.Keys; +using GoDaddy.Asherah.Crypto; +using GoDaddy.Asherah.Crypto.Exceptions; +using Microsoft.Extensions.Logging; using MetastoreKeyMeta = GoDaddy.Asherah.AppEncryption.Metastore.KeyMeta; @@ -27,42 +32,77 @@ internal sealed class EnvelopeEncryption : IEnvelopeEncryption private readonly Partition _partition; private readonly AeadEnvelopeCrypto _crypto; + private readonly IKeyMetastore _metastore; + private readonly SecureCryptoKeyDictionary _systemKeyCache; + private readonly SecureCryptoKeyDictionary _intermediateKeyCache; + private readonly CryptoPolicy _cryptoPolicy; + private readonly KeyManagementService _keyManagementService; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// The partition for this envelope encryption instance. /// The crypto implementation for envelope operations. - public EnvelopeEncryption(Partition partition, AeadEnvelopeCrypto crypto) + /// Policy that dictates crypto behaviors. + /// The metastore for storing and retrieving keys. + /// Cache for system keys. + /// Cache for intermediate keys. + /// Service for key management operations. + /// The logger implementation to use. + public EnvelopeEncryption( + Partition partition, + AeadEnvelopeCrypto crypto, + CryptoPolicy cryptoPolicy, + IKeyMetastore metastore, + SecureCryptoKeyDictionary systemKeyCache, + SecureCryptoKeyDictionary intermediateKeyCache, + KeyManagementService keyManagementService, + ILogger logger) { _partition = partition; _crypto = crypto; + _cryptoPolicy = cryptoPolicy; + _metastore = metastore; + _systemKeyCache = systemKeyCache; + _intermediateKeyCache = intermediateKeyCache; + _keyManagementService = keyManagementService; + _logger = logger; } /// public byte[] DecryptDataRowRecord(byte[] dataRowRecord) { - // Step 1: Deserialize the byte array into a strongly-typed DataRowRecord + return DecryptDataRowRecordAsync(dataRowRecord).GetAwaiter().GetResult(); + } + + /// + public byte[] EncryptPayload(byte[] payload) + { + throw new NotImplementedException("Implementation will be added later"); + } + + /// + public async Task DecryptDataRowRecordAsync(byte[] dataRowRecord) + { DataRowRecord dataRowRecordModel = DeserializeDataRowRecord(dataRowRecord); - // Step 2: Validate that we have ParentKeyMeta if (dataRowRecordModel.Key?.ParentKeyMeta == null) { throw new MetadataMissingException("Could not find parentKeyMeta {IK} for dataRowKey"); } - // Step 3: Validate intermediate key ID against partition if (!_partition.IsValidIntermediateKeyId(dataRowRecordModel.Key.ParentKeyMeta.KeyId)) { throw new MetadataMissingException("Could not find parentKeyMeta {IK} for dataRowKey"); } - // Step 4: Extract encrypted payload and key from base64 strings + // the Data property is a base64 encoded string containing the encrypted payload + // the Key property from the DataRowRecord.Key is a base64 encoded string containing the encrypted key byte[] payloadEncrypted = Convert.FromBase64String(dataRowRecordModel.Data); byte[] encryptedKey = Convert.FromBase64String(dataRowRecordModel.Key.Key); - // Step 5: Decrypt using intermediate key - byte[] decryptedPayload = WithIntermediateKeyForRead( + byte[] decryptedPayload = await WithIntermediateKeyForRead( dataRowRecordModel.Key.ParentKeyMeta, intermediateCryptoKey => _crypto.EnvelopeDecrypt( @@ -75,7 +115,7 @@ public byte[] DecryptDataRowRecord(byte[] dataRowRecord) } /// - public byte[] EncryptPayload(byte[] payload) + public Task EncryptPayloadAsync(byte[] payload) { throw new NotImplementedException("Implementation will be added later"); } @@ -86,18 +126,224 @@ public byte[] EncryptPayload(byte[] payload) /// intermediate key meta used previously to write a DRR. /// the function to call using the decrypted intermediate key. /// The result of the function execution. - private byte[] WithIntermediateKeyForRead( + private async Task WithIntermediateKeyForRead( IKeyMeta intermediateKeyMeta, Func functionWithIntermediateKey) { - throw new NotImplementedException("Implementation will be added later"); + CryptoKey intermediateKey = _intermediateKeyCache.Get(intermediateKeyMeta.Created); + + if (intermediateKey == null) + { + intermediateKey = await GetIntermediateKey(intermediateKeyMeta); + + // Put the key into our cache if allowed + if (_cryptoPolicy.CanCacheIntermediateKeys()) + { + try + { + intermediateKey = _intermediateKeyCache.PutAndGetUsable(intermediateKey.GetCreated(), intermediateKey); + } + catch (Exception ex) + { + DisposeKey(intermediateKey, ex); + throw new AppEncryptionException("Unable to update cache for Intermediate key", ex); + } + } + } + + return await ApplyFunctionAndDisposeKey(intermediateKey, key => Task.FromResult(functionWithIntermediateKey(key))); + } + + /// + /// Fetches a known intermediate key from metastore and decrypts it using its associated system key. + /// + /// + /// The decrypted intermediate key. + /// + /// The of intermediate key. + /// If the intermediate key is not found, or it has missing system + /// key info. + private async Task GetIntermediateKey(IKeyMeta intermediateKeyMeta) + { + var (found, intermediateKeyRecord) = await _metastore.TryLoadAsync(intermediateKeyMeta.KeyId, intermediateKeyMeta.Created); + + if (!found) + { + throw new MetadataMissingException($"Could not find EnvelopeKeyRecord with keyId = {intermediateKeyMeta.KeyId}, created = {intermediateKeyMeta.Created}"); + } + + if (intermediateKeyRecord.ParentKeyMeta == null) + { + throw new MetadataMissingException("Could not find parentKeyMeta (SK) for intermediateKey"); + } + + return await WithExistingSystemKey( + intermediateKeyRecord.ParentKeyMeta, + false, // treatExpiredAsMissing = false (allow expired keys) + systemKey => Task.FromResult(DecryptKey(intermediateKeyRecord, systemKey))); + } + + /// + /// Calls a function using a decrypted system key that was previously used. + /// + /// The type that the returns. + /// + /// The result returned by the . + /// + /// system key meta used previously to write an IK. + /// if true, will throw a + /// if the key is expired/revoked. + /// the function to call using the decrypted system key. + /// + /// If the system key is not found, or if its expired/revoked and + /// is true. + private async Task WithExistingSystemKey( + IKeyMeta systemKeyMeta, bool treatExpiredAsMissing, Func> functionWithSystemKey) + { + // Get from cache or lookup previously used key + CryptoKey systemKey = _systemKeyCache.Get(systemKeyMeta.Created); + + if (systemKey == null) + { + systemKey = await GetSystemKey(systemKeyMeta); + + // Put the key into our cache if allowed + if (_cryptoPolicy.CanCacheSystemKeys()) + { + try + { + systemKey = _systemKeyCache.PutAndGetUsable(systemKeyMeta.Created, systemKey); + } + catch (Exception ex) + { + DisposeKey(systemKey, ex); + throw new AppEncryptionException("Unable to update cache for SystemKey", ex); + } + } + } + + if (IsKeyExpiredOrRevoked(systemKey)) + { + if (treatExpiredAsMissing) + { + DisposeKey(systemKey, null); + throw new MetadataMissingException("System key is expired/revoked, keyMeta = " + systemKeyMeta); + } + } + + return await ApplyFunctionAndDisposeKey(systemKey, functionWithSystemKey); + } + + /// + /// Fetches a known system key from metastore and decrypts it using the key management service. + /// + /// + /// The decrypted system key. + /// + /// The of the system key. + /// If the system key is not found. + private async Task GetSystemKey(IKeyMeta systemKeyMeta) + { + var (found, systemKeyRecord) = await _metastore.TryLoadAsync(systemKeyMeta.KeyId, systemKeyMeta.Created); + + if (!found) + { + throw new MetadataMissingException($"Could not find EnvelopeKeyRecord with keyId = {systemKeyMeta.KeyId}, created = {systemKeyMeta.Created}"); + } + + return _keyManagementService.DecryptKey( + Convert.FromBase64String(systemKeyRecord.Key), + systemKeyRecord.Created, + systemKeyRecord.Revoked ?? false); + } + + /// + /// Decrypts the 's encrypted key using the provided key. + /// + /// + /// The decrypted key contained in the . + /// + /// The key to decrypt. + /// Encryption key to use for decryption. + private CryptoKey DecryptKey(IKeyRecord keyRecord, CryptoKey keyEncryptionKey) + { + return _crypto.DecryptKey( + Convert.FromBase64String(keyRecord.Key), + keyRecord.Created, + keyEncryptionKey, + keyRecord.Revoked ?? false); + } + + /// + /// Checks if a key is expired or revoked. + /// + /// The crypto key to check. + /// True if the key is expired or revoked, false otherwise. + private bool IsKeyExpiredOrRevoked(CryptoKey cryptoKey) + { + return _cryptoPolicy.IsKeyExpired(cryptoKey.GetCreated()) || cryptoKey.IsRevoked(); + } + + /// + /// Applies a function with a crypto key and ensures the key is properly disposed afterward. + /// + /// The crypto key to use. + /// The function to execute with the key. + /// The result of the function execution. + private static async Task ApplyFunctionAndDisposeKey(CryptoKey key, Func> functionWithKey) + { + try + { + return await functionWithKey(key); + } + catch (Exception ex) + { + throw new AppEncryptionException($"Failed call action method, error: {ex.Message}", ex); + } + finally + { + DisposeKey(key, null); + } } /// public void Dispose() { - // Implementation will be added later + try + { + // only close intermediate key cache since its lifecycle is tied to this "session" + _intermediateKeyCache.Dispose(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Unexpected exception during dispose"); + } } + /// + /// Disposes a crypto key with proper error handling. + /// + /// The key to dispose. + /// The root exception that caused the disposal, if any. + private static void DisposeKey(CryptoKey cryptoKey, Exception rootException) + { + try + { + cryptoKey.Dispose(); + } + catch (Exception ex) + { + if (rootException != null) + { + AggregateException aggregateException = new AggregateException(ex, rootException); + throw new AppEncryptionException( + $"Failed to dispose/wipe key, error: {ex.Message}", aggregateException); + } + + throw new AppEncryptionException($"Failed to dispose/wipe key, error: {ex.Message}", ex); + } + } + + /// /// Deserializes a byte array containing UTF-8 JSON into a strongly-typed DataRowRecord. /// diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionBytesImpl.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionBytesImpl.cs index f9a6e6c5b..8e989df2d 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionBytesImpl.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionBytesImpl.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.CompilerServices; +using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Util; using Microsoft.Extensions.Logging; using Newtonsoft.Json.Linq; @@ -69,5 +70,17 @@ public virtual byte[] EncryptPayload(byte[] payload) Json drrJson = new Json(envelopeEncryptionJson.EncryptPayload(payload)); return drrJson.ToUtf8(); } + + /// + public virtual async Task DecryptDataRowRecordAsync(byte[] dataRowRecord) + { + return await Task.FromResult(DecryptDataRowRecord(dataRowRecord)); + } + + /// + public virtual async Task EncryptPayloadAsync(byte[] payload) + { + return await Task.FromResult(EncryptPayload(payload)); + } } } diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs index 7070a354f..183bbf3c5 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Runtime.CompilerServices; +using System.Threading.Tasks; using App.Metrics.Timer; using GoDaddy.Asherah.AppEncryption.Exceptions; using GoDaddy.Asherah.AppEncryption.Kms; @@ -166,6 +167,18 @@ public virtual JObject EncryptPayload(byte[] payload) } } + /// + public virtual async Task DecryptDataRowRecordAsync(JObject dataRowRecord) + { + return await Task.FromResult(DecryptDataRowRecord(dataRowRecord)); + } + + /// + public virtual async Task EncryptPayloadAsync(byte[] payload) + { + return await Task.FromResult(EncryptPayload(payload)); + } + /// public virtual void Dispose() { diff --git a/csharp/AppEncryption/AppEncryption/Envelope/IEnvelopeEncryption.cs b/csharp/AppEncryption/AppEncryption/Envelope/IEnvelopeEncryption.cs index 96c0317c5..554919c58 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/IEnvelopeEncryption.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/IEnvelopeEncryption.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; namespace GoDaddy.Asherah.AppEncryption.Envelope { @@ -27,5 +28,21 @@ public interface IEnvelopeEncryption : IDisposable /// Payload to encrypt. /// The Data Row Record that contains the now-encrypted payload and corresponding Data Row Key. TD EncryptPayload(byte[] payload); + + /// + /// Uses an envelope encryption algorithm to decrypt a Data Row Record and return the payload asynchronously. + /// + /// + /// Value to decrypt. + /// A decrypted payload as bytes. + Task DecryptDataRowRecordAsync(TD dataRowRecord); + + /// + /// Uses an envelope encryption algorithm to encrypt a payload and return the resulting Data Row Record asynchronously. + /// + /// + /// Payload to encrypt. + /// The Data Row Record that contains the now-encrypted payload and corresponding Data Row Key. + Task EncryptPayloadAsync(byte[] payload); } } diff --git a/csharp/AppEncryption/AppEncryption/Session.cs b/csharp/AppEncryption/AppEncryption/Session.cs index 7b81732d2..390cbf3e7 100644 --- a/csharp/AppEncryption/AppEncryption/Session.cs +++ b/csharp/AppEncryption/AppEncryption/Session.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.CompilerServices; +using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Persistence; using LanguageExt; @@ -36,6 +37,24 @@ public abstract class Session : IDisposable /// The Data Row Record that contains the now-encrypted payload. public abstract TD Encrypt(TP payLoad); + /// + /// Decrypts a Data Row Record based on an implementation-specific encryption algorithm and returns the actual + /// payload asynchronously. + /// + /// + /// The DRR to be decrypted. + /// The decrypted payload. + public abstract Task DecryptAsync(TD dataRowRecord); + + /// + /// Encrypts a payload using an implementation-specific encryption algorithm and returns the Data Row Record + /// that contains it asynchronously. + /// + /// + /// The payload to be encrypted. + /// The Data Row Record that contains the now-encrypted payload. + public abstract Task EncryptAsync(TP payLoad); + /// /// Uses a persistence key to load a Data Row Record from the provided data persistence store, if any, /// and returns the decrypted payload. diff --git a/csharp/AppEncryption/AppEncryption/SessionBytesImpl.cs b/csharp/AppEncryption/AppEncryption/SessionBytesImpl.cs index 2a5eb8608..665620ca9 100644 --- a/csharp/AppEncryption/AppEncryption/SessionBytesImpl.cs +++ b/csharp/AppEncryption/AppEncryption/SessionBytesImpl.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.CompilerServices; +using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Envelope; using Microsoft.Extensions.Logging; @@ -54,6 +55,18 @@ public override TD Encrypt(byte[] payload) return envelopeEncryption.EncryptPayload(payload); } + /// + public override async Task DecryptAsync(TD dataRowRecord) + { + return await Task.FromResult(Decrypt(dataRowRecord)); + } + + /// + public override async Task EncryptAsync(byte[] payload) + { + return await Task.FromResult(Encrypt(payload)); + } + /// public override void Dispose() { diff --git a/csharp/AppEncryption/AppEncryption/SessionFactory.cs b/csharp/AppEncryption/AppEncryption/SessionFactory.cs index 6b38b1929..33ef93e89 100644 --- a/csharp/AppEncryption/AppEncryption/SessionFactory.cs +++ b/csharp/AppEncryption/AppEncryption/SessionFactory.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Runtime.CompilerServices; +using System.Threading.Tasks; using App.Metrics; using App.Metrics.Concurrency; using GoDaddy.Asherah.AppEncryption.Envelope; @@ -467,6 +468,16 @@ public JObject EncryptPayload(byte[] payload) return envelopeEncryptionJsonImpl.EncryptPayload(payload); } + public async Task DecryptDataRowRecordAsync(JObject dataRowRecord) + { + return await envelopeEncryptionJsonImpl.DecryptDataRowRecordAsync(dataRowRecord); + } + + public async Task EncryptPayloadAsync(byte[] payload) + { + return await envelopeEncryptionJsonImpl.EncryptPayloadAsync(payload); + } + internal void IncrementUsageTracker() { usageCounter.Increment(); diff --git a/csharp/AppEncryption/AppEncryption/SessionJsonImpl.cs b/csharp/AppEncryption/AppEncryption/SessionJsonImpl.cs index a565bdc80..2f945feab 100644 --- a/csharp/AppEncryption/AppEncryption/SessionJsonImpl.cs +++ b/csharp/AppEncryption/AppEncryption/SessionJsonImpl.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.CompilerServices; +using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Envelope; using GoDaddy.Asherah.AppEncryption.Util; using Microsoft.Extensions.Logging; @@ -58,6 +59,18 @@ public override TD Encrypt(JObject payload) return envelopeEncryption.EncryptPayload(jsonAsUtf8Bytes); } + /// + public override async Task DecryptAsync(TD dataRowRecord) + { + return await Task.FromResult(Decrypt(dataRowRecord)); + } + + /// + public override async Task EncryptAsync(JObject payload) + { + return await Task.FromResult(Encrypt(payload)); + } + /// public override void Dispose() { From c9781d8fe451b18676a1bdfc76528a132c980ab2 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Mon, 8 Sep 2025 06:32:14 -0700 Subject: [PATCH 08/31] added new interface for kms --- .../Kms/IKeyManagementService.cs | 32 +++++++++++++++++++ .../AppEncryption/Kms/KeyManagementService.cs | 27 +++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 csharp/AppEncryption/AppEncryption/Kms/IKeyManagementService.cs diff --git a/csharp/AppEncryption/AppEncryption/Kms/IKeyManagementService.cs b/csharp/AppEncryption/AppEncryption/Kms/IKeyManagementService.cs new file mode 100644 index 000000000..461fdc694 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Kms/IKeyManagementService.cs @@ -0,0 +1,32 @@ +using System; +using System.Threading.Tasks; +using GoDaddy.Asherah.Crypto.Keys; + +namespace GoDaddy.Asherah.AppEncryption.Kms +{ + /// + /// A key management service is a pluggable component used by Asherah which generates the top level master key, + /// which in turn is used to encrypt the system keys. It enables the user to use a HSM for providing the master key + /// or staying cloud agnostic if using a hosted key management service. + /// + public interface IKeyManagementService + { + /// + /// Encrypts a using the implemented key management service. + /// + /// + /// The key to encrypt. + /// The encrypted key in form of a byte[]. + Task EncryptKeyAsync(CryptoKey key); + + /// + /// Takes the encrypted key as the parameter and decrypts the key using the implemented key management service. + /// + /// + /// The encrypted key. + /// Creation time of the key. + /// Revoked status of the key. True if revoked, else False. + /// The decrypted key. + Task DecryptKeyAsync(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked); + } +} diff --git a/csharp/AppEncryption/AppEncryption/Kms/KeyManagementService.cs b/csharp/AppEncryption/AppEncryption/Kms/KeyManagementService.cs index 87d07a72a..2450a45a7 100644 --- a/csharp/AppEncryption/AppEncryption/Kms/KeyManagementService.cs +++ b/csharp/AppEncryption/AppEncryption/Kms/KeyManagementService.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; using GoDaddy.Asherah.Crypto.Keys; namespace GoDaddy.Asherah.AppEncryption.Kms @@ -8,7 +9,7 @@ namespace GoDaddy.Asherah.AppEncryption.Kms /// which in turn is used to encrypt the system keys. It enables the user to use a HSM for providing the master key /// or staying cloud agnostic if using a hosted key management service. /// - public abstract class KeyManagementService + public abstract class KeyManagementService : IKeyManagementService { /// /// Encrypts a using the implemented key management service. @@ -28,6 +29,30 @@ public abstract class KeyManagementService /// The decrypted key. public abstract CryptoKey DecryptKey(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked); + /// + /// Encrypts a using the implemented key management service. + /// + /// + /// The key to encrypt. + /// The encrypted key in form of a byte[]. + public virtual async Task EncryptKeyAsync(CryptoKey key) + { + return await Task.FromResult(EncryptKey(key)); + } + + /// + /// Takes the encrypted key as the parameter and decrypts the key using the implemented key management service. + /// + /// + /// The encrypted key. + /// Creation time of the key. + /// Revoked status of the key. True if revoked, else False. + /// The decrypted key. + public virtual async Task DecryptKeyAsync(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked) + { + return await Task.FromResult(DecryptKey(keyCipherText, keyCreated, revoked)); + } + /// /// Decrypts a certain key using the implemented key management service and then applies /// on the decrypted key. From ba14b08a939ece6a5a913eee3f6596a9a22c9183 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Mon, 8 Sep 2025 13:27:39 -0700 Subject: [PATCH 09/31] Implemented new KeyManagement encrypt --- .../AppEncryption.Extensions.Aws.csproj} | 3 + .../Kms/IKeyManagementClientFactory.cs | 17 ++ .../Kms/KeyManagementService.cs | 249 ++++++++++++++++++ .../Kms/KeyManagementServiceOptions.cs | 17 ++ .../Kms/KmsArnClient.cs | 38 +++ .../Kms/RegionKeyArn.cs | 22 ++ .../Metastore/DynamoDbMetastore.cs | 0 .../Metastore/DynamoDbMetastoreOptions.cs | 0 .../AppEncryption.Tests.csproj | 2 +- csharp/AppEncryption/AppEncryption.slnx | 2 +- .../Envelope/EnvelopeEncryption.cs | 4 +- .../Envelope/EnvelopeEncryptionJsonImpl.cs | 10 +- .../Kms/IKeyManagementService.cs | 18 ++ .../AppEncryption/SessionFactory.cs | 18 +- 14 files changed, 382 insertions(+), 18 deletions(-) rename csharp/AppEncryption/{Extensions.Aws/Extensions.Aws.csproj => AppEncryption.Extensions.Aws/AppEncryption.Extensions.Aws.csproj} (87%) create mode 100644 csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/IKeyManagementClientFactory.cs create mode 100644 csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs create mode 100644 csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementServiceOptions.cs create mode 100644 csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KmsArnClient.cs create mode 100644 csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/RegionKeyArn.cs rename csharp/AppEncryption/{Extensions.Aws => AppEncryption.Extensions.Aws}/Metastore/DynamoDbMetastore.cs (100%) rename csharp/AppEncryption/{Extensions.Aws => AppEncryption.Extensions.Aws}/Metastore/DynamoDbMetastoreOptions.cs (100%) diff --git a/csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj b/csharp/AppEncryption/AppEncryption.Extensions.Aws/AppEncryption.Extensions.Aws.csproj similarity index 87% rename from csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj rename to csharp/AppEncryption/AppEncryption.Extensions.Aws/AppEncryption.Extensions.Aws.csproj index 22a341e08..a8df3ea6a 100644 --- a/csharp/AppEncryption/Extensions.Aws/Extensions.Aws.csproj +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/AppEncryption.Extensions.Aws.csproj @@ -24,8 +24,11 @@ + + + diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/IKeyManagementClientFactory.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/IKeyManagementClientFactory.cs new file mode 100644 index 000000000..c8c6be0c5 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/IKeyManagementClientFactory.cs @@ -0,0 +1,17 @@ +using Amazon.KeyManagementService; + +namespace GoDaddy.Asherah.AppEncryption.Extensions.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.Extensions.Aws/Kms/KeyManagementService.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs new file mode 100644 index 000000000..e12483dab --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs @@ -0,0 +1,249 @@ +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.Extensions.Aws.Kms +{ + /// + /// AWS-specific implementation of . + /// + public sealed class KeyManagementService : IKeyManagementService, IDisposable + { + private readonly KeyManagementServiceOptions _kmsOptions; + private readonly IKeyManagementClientFactory _clientFactory; + 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 ARN {Arn} KMS, trying next ARN"); + + private static readonly Action LogEncryptError = LoggerMessage.Define( + LogLevel.Error, + new EventId(2, nameof(KeyManagementService)), + "Unexpected execution exception while encrypting KMS data key"); + + /// + /// 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) + { + _kmsOptions = kmsOptions; + _clientFactory = clientFactory; + _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 Task DecryptKeyAsync(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked) + { + throw new NotImplementedException(); + } + + /// + public async Task EncryptKeyAsync(CryptoKey key) + { + var (dataKey, dataKeyKeyId) = await GenerateDataKeyAsync(); + byte[] dataKeyPlainText = dataKey.Plaintext.GetBuffer(); + + try + { + var dataKeyCryptoKey = _crypto.GenerateKeyFromBytes(dataKeyPlainText); + byte[] 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.Arn, ex); + } + } + + throw new KmsException("could not successfully generate data key using any regions"); + } + + /// + /// 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 + byte[] 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.Extensions.Aws/Kms/KeyManagementServiceOptions.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementServiceOptions.cs new file mode 100644 index 000000000..0b1f8f3a2 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementServiceOptions.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace GoDaddy.Asherah.AppEncryption.Extensions.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.Extensions.Aws/Kms/KmsArnClient.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KmsArnClient.cs new file mode 100644 index 000000000..4ae891c08 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KmsArnClient.cs @@ -0,0 +1,38 @@ +using Amazon.KeyManagementService; + +namespace GoDaddy.Asherah.AppEncryption.Extensions.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.Extensions.Aws/Kms/RegionKeyArn.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/RegionKeyArn.cs new file mode 100644 index 000000000..64eb3beec --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/RegionKeyArn.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; + +namespace GoDaddy.Asherah.AppEncryption.Extensions.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/Extensions.Aws/Metastore/DynamoDbMetastore.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastore.cs similarity index 100% rename from csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastore.cs rename to csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastore.cs diff --git a/csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs similarity index 100% rename from csharp/AppEncryption/Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs rename to csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index d2d527484..89409a82c 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -34,6 +34,6 @@ - + diff --git a/csharp/AppEncryption/AppEncryption.slnx b/csharp/AppEncryption/AppEncryption.slnx index 0874b30a5..7c01030df 100644 --- a/csharp/AppEncryption/AppEncryption.slnx +++ b/csharp/AppEncryption/AppEncryption.slnx @@ -2,6 +2,6 @@ - + diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs index 350779882..c0b8076e7 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs @@ -36,7 +36,7 @@ internal sealed class EnvelopeEncryption : IEnvelopeEncryption private readonly SecureCryptoKeyDictionary _systemKeyCache; private readonly SecureCryptoKeyDictionary _intermediateKeyCache; private readonly CryptoPolicy _cryptoPolicy; - private readonly KeyManagementService _keyManagementService; + private readonly IKeyManagementService _keyManagementService; private readonly ILogger _logger; /// @@ -57,7 +57,7 @@ public EnvelopeEncryption( IKeyMetastore metastore, SecureCryptoKeyDictionary systemKeyCache, SecureCryptoKeyDictionary intermediateKeyCache, - KeyManagementService keyManagementService, + IKeyManagementService keyManagementService, ILogger logger) { _partition = partition; diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs index 183bbf3c5..b6b82d693 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryptionJsonImpl.cs @@ -35,7 +35,7 @@ public class EnvelopeEncryptionJsonImpl : IEnvelopeEncryption private readonly SecureCryptoKeyDictionary intermediateKeyCache; private readonly AeadEnvelopeCrypto crypto; private readonly CryptoPolicy cryptoPolicy; - private readonly KeyManagementService keyManagementService; + private readonly IKeyManagementService keyManagementService; /// /// Initializes a new instance of the class using the provided @@ -55,7 +55,7 @@ public class EnvelopeEncryptionJsonImpl : IEnvelopeEncryption /// envelopes. /// A implementation that dictates /// the various behaviors of Asherah. - /// A + /// A /// implementation that generates the top level master key and encrypts the system keys using the master key. /// /// The logger implementation to use. @@ -66,7 +66,7 @@ public EnvelopeEncryptionJsonImpl( SecureCryptoKeyDictionary intermediateKeyCache, AeadEnvelopeCrypto aeadEnvelopeCrypto, CryptoPolicy cryptoPolicy, - KeyManagementService keyManagementService, + IKeyManagementService keyManagementService, ILogger logger) { this.partition = partition; @@ -97,7 +97,7 @@ public EnvelopeEncryptionJsonImpl( /// envelopes. /// A implementation that dictates /// the various behaviors of Asherah. - /// A + /// A /// implementation that generates the top level master key and encrypts the system keys using the master key. /// public EnvelopeEncryptionJsonImpl( @@ -107,7 +107,7 @@ public EnvelopeEncryptionJsonImpl( SecureCryptoKeyDictionary intermediateKeyCache, AeadEnvelopeCrypto aeadEnvelopeCrypto, CryptoPolicy cryptoPolicy, - KeyManagementService keyManagementService) + IKeyManagementService keyManagementService) : this(partition, metastore, systemKeyCache, intermediateKeyCache, aeadEnvelopeCrypto, cryptoPolicy, keyManagementService, null) { } diff --git a/csharp/AppEncryption/AppEncryption/Kms/IKeyManagementService.cs b/csharp/AppEncryption/AppEncryption/Kms/IKeyManagementService.cs index 461fdc694..cb022fdcf 100644 --- a/csharp/AppEncryption/AppEncryption/Kms/IKeyManagementService.cs +++ b/csharp/AppEncryption/AppEncryption/Kms/IKeyManagementService.cs @@ -11,6 +11,24 @@ namespace GoDaddy.Asherah.AppEncryption.Kms /// public interface IKeyManagementService { + /// + /// Encrypts a using the implemented key management service. + /// + /// + /// The key to encrypt. + /// The encrypted key in form of a byte[]. + byte[] EncryptKey(CryptoKey key); + + /// + /// Takes the encrypted key as the parameter and decrypts the key using the implemented key management service. + /// + /// + /// The encrypted key. + /// Creation time of the key. + /// Revoked status of the key. True if revoked, else False. + /// The decrypted key. + CryptoKey DecryptKey(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked); + /// /// Encrypts a using the implemented key management service. /// diff --git a/csharp/AppEncryption/AppEncryption/SessionFactory.cs b/csharp/AppEncryption/AppEncryption/SessionFactory.cs index 33ef93e89..afb4ded88 100644 --- a/csharp/AppEncryption/AppEncryption/SessionFactory.cs +++ b/csharp/AppEncryption/AppEncryption/SessionFactory.cs @@ -41,7 +41,7 @@ public class SessionFactory : IDisposable private readonly IMetastore metastore; private readonly SecureCryptoKeyDictionary systemKeyCache; private readonly CryptoPolicy cryptoPolicy; - private readonly KeyManagementService keyManagementService; + private readonly IKeyManagementService keyManagementService; private readonly ConcurrentDictionary semaphoreLocks; /// @@ -56,7 +56,7 @@ public class SessionFactory : IDisposable /// caching system keys. /// A implementation that dictates /// the various behaviors of Asherah. - /// A + /// A /// implementation that generates the top level master key and encrypts the system keys using the master key. /// public SessionFactory( @@ -65,7 +65,7 @@ public SessionFactory( IMetastore metastore, SecureCryptoKeyDictionary systemKeyCache, CryptoPolicy cryptoPolicy, - KeyManagementService keyManagementService) + IKeyManagementService keyManagementService) : this(productId, serviceId, metastore, systemKeyCache, cryptoPolicy, keyManagementService, null) { } @@ -82,7 +82,7 @@ public SessionFactory( /// caching system keys. /// A implementation that dictates /// the various behaviors of Asherah. - /// A + /// A /// implementation that generates the top level master key and encrypts the system keys using the master key. /// /// A logger implementation. @@ -92,7 +92,7 @@ public SessionFactory( IMetastore metastore, SecureCryptoKeyDictionary systemKeyCache, CryptoPolicy cryptoPolicy, - KeyManagementService keyManagementService, + IKeyManagementService keyManagementService, ILogger logger) { this.productId = productId; @@ -168,11 +168,11 @@ public interface IKeyManagementServiceStep /// Initialize a session factory builder step with the provided key management service. /// /// - /// The implementation to use. + /// The implementation to use. /// /// The current instance initialized with some /// implementation. - IBuildStep WithKeyManagementService(KeyManagementService keyManagementService); + IBuildStep WithKeyManagementService(IKeyManagementService keyManagementService); } public interface IBuildStep @@ -506,7 +506,7 @@ private class Builder : IMetastoreStep, ICryptoPolicyStep, IKeyManagementService private IMetastore metastore; private CryptoPolicy cryptoPolicy; - private KeyManagementService keyManagementService; + private IKeyManagementService keyManagementService; private IMetrics metrics; private ILogger _logger; @@ -546,7 +546,7 @@ public IBuildStep WithStaticKeyManagementService(string staticMasterKey) return this; } - public IBuildStep WithKeyManagementService(KeyManagementService kms) + public IBuildStep WithKeyManagementService(IKeyManagementService kms) { keyManagementService = kms; return this; From 13bc19092f48d87b34c8821df881313826a5b6c4 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Mon, 8 Sep 2025 15:52:02 -0700 Subject: [PATCH 10/31] implemented Decrypt --- .../Kms/KeyManagementClientFactory.cs | 46 ++++++++++ .../Kms/KeyManagementService.cs | 87 +++++++++++++++++-- 2 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementClientFactory.cs diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementClientFactory.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementClientFactory.cs new file mode 100644 index 000000000..a03e57a21 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementClientFactory.cs @@ -0,0 +1,46 @@ +using System; +using Amazon.KeyManagementService; +using Amazon.Runtime; + +namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms +{ + /// + /// Simple implementation of that creates KMS clients + /// for any region using provided AWS credentials. + /// + 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)); + } + + var regionEndpoint = Amazon.RegionEndpoint.GetBySystemName(region); + if (regionEndpoint == null) + { + throw new ArgumentException($"Invalid AWS region: {region}", nameof(region)); + } + + var config = new AmazonKeyManagementServiceConfig + { + RegionEndpoint = regionEndpoint + }; + + return new AmazonKeyManagementServiceClient(_credentials, config); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs index e12483dab..650a3423c 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs @@ -30,13 +30,18 @@ public sealed class KeyManagementService : IKeyManagementService, IDisposable private static readonly Action LogFailedGenerateDataKey = LoggerMessage.Define( LogLevel.Warning, new EventId(1, nameof(KeyManagementService)), - "Failed to generate data key via ARN {Arn} KMS, trying next ARN"); + "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"); + /// /// Initializes a new instance of the class. /// @@ -74,9 +79,34 @@ public CryptoKey DecryptKey(byte[] keyCipherText, DateTimeOffset keyCreated, boo } /// - public Task DecryptKeyAsync(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked) + public async Task DecryptKeyAsync(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked) { - throw new NotImplementedException(); + var kmsKeyEnvelope = JsonSerializer.Deserialize(keyCipherText); + byte[] 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; + } + + byte[] 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"); } /// @@ -150,11 +180,58 @@ public async Task EncryptKeyAsync(CryptoKey key) } catch (Exception ex) { - LogFailedGenerateDataKey(_logger, kmsArnClient.Arn, ex); + LogFailedGenerateDataKey(_logger, kmsArnClient.Region, ex); } } - throw new KmsException("could not successfully generate data key using any regions"); + 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 (MemoryStream ciphertextBlobStream = new MemoryStream(kmsKeyEncryptionKey)) + { + DecryptRequest 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 (MemoryStream 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); + } + } } /// From c12db3fba9bd47fd57bd2d5b1a908327a5c8422c Mon Sep 17 00:00:00 2001 From: chief-micco Date: Tue, 9 Sep 2025 14:23:37 -0700 Subject: [PATCH 11/31] tests for new KeyManagementService in aws extensions --- .../Kms/KeyManagementService.cs | 6 +- .../Aws/Kms/AwsKeyManagementStub.cs | 496 ++++++++++++++++++ .../Aws/Kms/KeyManagementClientFactoryStub.cs | 52 ++ .../Aws/Kms/KeyManagementServiceTests.cs | 147 ++++++ .../Aws/Metastore/DynamoDbMetastoreTests.cs | 2 +- .../Kms/AwsKeyManagementServiceImplTest.cs | 10 + .../AppEncryption/TestHelpers/LogEntry.cs | 16 + .../TestHelpers/LoggerFactoryStub.cs | 35 ++ .../AppEncryption/TestHelpers/LoggerStub.cs | 35 ++ .../AppEncryption.Tests/GlobalSuppressions.cs | 1 + 10 files changed, 797 insertions(+), 3 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs rename csharp/AppEncryption/AppEncryption.Tests/{ => AppEncryption}/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs (99%) 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 diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs index 650a3423c..eb09708d9 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs @@ -220,8 +220,10 @@ private async Task DecryptKmsEncryptedKey( // Use proper disposal of the response plaintext stream and securely handle the sensitive bytes using (MemoryStream plaintextStream = response.Plaintext) { - // Extract the plaintext bytes so we can wipe them in case of an exception - plaintextBackingBytes = plaintextStream.GetBuffer(); + // Get the plaintext bytes + plaintextBackingBytes = new byte[plaintextStream.Length]; + plaintextStream.Position = 0; + plaintextStream.Read(plaintextBackingBytes, 0, plaintextBackingBytes.Length); try { diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs new file mode 100644 index 000000000..e8675c08e --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs @@ -0,0 +1,496 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Amazon.KeyManagementService; +using Amazon.KeyManagementService.Model; +using Amazon.Runtime; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.Aws.Kms +{ + /// + /// Stub implementation of IAmazonKeyManagementService for testing purposes. + /// + [ExcludeFromCodeCoverage] + public class AwsKeyManagementStub : IAmazonKeyManagementService + { + private readonly string _keyArn; + private readonly byte[] _keyBytes; + + /// + /// Initializes a new instance of the class. + /// + /// The key ARN for this stub. + public AwsKeyManagementStub(string keyArn) + { + _keyArn = keyArn; + _keyBytes = System.Text.Encoding.UTF8.GetBytes(_keyArn); + } + + public Amazon.Runtime.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 + byte[] ciphertext; + using (var stream = request.CiphertextBlob) + { + ciphertext = new byte[stream.Length]; + stream.Read(ciphertext, 0, ciphertext.Length); + } + + // Verify that the first bytes match the _keyBytes + if (ciphertext.Length < _keyBytes.Length) + { + throw new ArgumentException($"Ciphertext too short. Expected at least {_keyBytes.Length} bytes, got {ciphertext.Length}"); + } + + for (int i = 0; i < _keyBytes.Length; i++) + { + if (ciphertext[i] != _keyBytes[i]) + { + throw new ArgumentException($"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) + }; + + 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.Read(plaintext, 0, plaintext.Length); + } + + // 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}'"); + } + + // Generate fake data based on the _keyArn to make it unique per ARN + byte[] fakePlaintext = GenerateFakeDataFromArn(_keyArn, "plaintext"); + byte[] 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/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs new file mode 100644 index 000000000..580823056 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Amazon.KeyManagementService; +using GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.Aws.Kms +{ + /// + /// Stub implementation of IKeyManagementClientFactory for testing purposes. + /// + [ExcludeFromCodeCoverage] + public class KeyManagementClientFactoryStub : IKeyManagementClientFactory + { + private readonly KeyManagementServiceOptions _options; + private readonly Dictionary _clients = new Dictionary(); + + /// + /// Gets the dictionary of created clients by region. + /// + public IReadOnlyDictionary Clients => _clients; + + /// + /// Initializes a new instance of the class. + /// + /// The key management service options. + public KeyManagementClientFactoryStub(KeyManagementServiceOptions options) + { + _options = options; + } + + /// + 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)); + if (regionKeyArn == null) + { + throw new InvalidOperationException($"No key ARN found for region: {region}"); + } + + var client = new AwsKeyManagementStub(regionKeyArn.KeyArn); + _clients[region] = client; + return client; + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs new file mode 100644 index 000000000..e78c3cc85 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms; +using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers; +using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; +using GoDaddy.Asherah.Crypto.ExtensionMethods; +using GoDaddy.Asherah.Crypto.Keys; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.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 UsWest1 = "us-west-1"; + private const string ArnUsWest1 = "arn-us-west-1"; + + private readonly KeyManagementServiceOptions _keyManagementServiceOptions; + private readonly KeyManagementService _keyManagementService; + private readonly LoggerFactoryStub _loggerFactoryStub; + private readonly KeyManagementClientFactoryStub _clientFactoryStub; + + public KeyManagementServiceTests() + { + _keyManagementServiceOptions = new KeyManagementServiceOptions + { + RegionKeyArns = new List + { + new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 }, + new RegionKeyArn { Region = UsWest1, KeyArn = ArnUsWest1 } + } + }; + + _loggerFactoryStub = new LoggerFactoryStub(); + _clientFactoryStub = new KeyManagementClientFactoryStub(_keyManagementServiceOptions); + _keyManagementService = new KeyManagementService(_keyManagementServiceOptions, _clientFactoryStub, _loggerFactoryStub); + } + + [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 _keyManagementService.EncryptKeyAsync(key); + + // Assert + Assert.NotNull(result); + + // Deserialize and validate JSON structure + var jsonNode = System.Text.Json.Nodes.JsonNode.Parse(result); + + // 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-1"); + + // 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-1"); + + // 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-1", 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 _keyManagementService.EncryptKeyAsync(originalKey); + var decryptedKey = await _keyManagementService.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false); + + // Assert + Assert.NotNull(decryptedKey); + } + + public void Dispose() + { + _keyManagementService?.Dispose(); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs similarity index 99% rename from csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs rename to csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs index eb0406ce3..9fb2fd0b7 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs @@ -11,7 +11,7 @@ using Moq; using Xunit; -namespace GoDaddy.Asherah.AppEncryption.Tests.Extensions.Aws.Metastore; +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.Aws.Metastore; [ExcludeFromCodeCoverage] public class DynamoDbMetastoreTests : IClassFixture, IDisposable diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs index 99d1ad9b5..b3be05eaf 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs @@ -561,5 +561,15 @@ 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() + { + + } } } 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..be47641a8 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerStub.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +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 index 7c6bb4a4d..126767a82 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/GlobalSuppressions.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/GlobalSuppressions.cs @@ -7,3 +7,4 @@ [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")] From 5d1371e28bd1fdec100aeec208ee0f3ace555ca7 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Tue, 9 Sep 2025 15:09:46 -0700 Subject: [PATCH 12/31] Fixes to stub to allow output streams to be publicly visible --- .../Kms/KeyManagementService.cs | 6 +-- .../Aws/Kms/AwsKeyManagementStub.cs | 10 ++--- .../Aws/Kms/KeyManagementClientFactoryStub.cs | 37 ++++++++----------- .../Aws/Kms/KeyManagementServiceTests.cs | 21 ++++------- .../Kms/AwsKeyManagementServiceImplTest.cs | 33 ++++++++++++++++- .../AppEncryption/TestHelpers/LoggerStub.cs | 1 - .../AppEncryption/Envelope/DataRowRecord.cs | 1 - 7 files changed, 60 insertions(+), 49 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs index eb09708d9..650a3423c 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs +++ b/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs @@ -220,10 +220,8 @@ private async Task DecryptKmsEncryptedKey( // Use proper disposal of the response plaintext stream and securely handle the sensitive bytes using (MemoryStream plaintextStream = response.Plaintext) { - // Get the plaintext bytes - plaintextBackingBytes = new byte[plaintextStream.Length]; - plaintextStream.Position = 0; - plaintextStream.Read(plaintextBackingBytes, 0, plaintextBackingBytes.Length); + // Extract the plaintext bytes so we can wipe them in case of an exception + plaintextBackingBytes = plaintextStream.GetBuffer(); try { diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs index e8675c08e..4b66ddd52 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Amazon.KeyManagementService; @@ -75,12 +73,12 @@ public Task CreateKeyAsync(CreateKeyRequest request, Cancella public Task DecryptAsync(DecryptRequest request, CancellationToken cancellationToken = default) { - // Read the ciphertext from the request + // 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.Read(ciphertext, 0, ciphertext.Length); + stream.ReadExactly(ciphertext, 0, ciphertext.Length); } // Verify that the first bytes match the _keyBytes @@ -104,7 +102,7 @@ public Task DecryptAsync(DecryptRequest request, CancellationTo // Simply copy the modified bytes to plaintext var response = new DecryptResponse { - Plaintext = new MemoryStream(plaintext) + Plaintext = new MemoryStream(plaintext, 0, plaintext.Length, false, true), }; return Task.FromResult(response); @@ -208,7 +206,7 @@ public Task EncryptAsync(EncryptRequest request, CancellationTo using (var stream = request.Plaintext) { plaintext = new byte[stream.Length]; - stream.Read(plaintext, 0, plaintext.Length); + stream.ReadExactly(plaintext, 0, plaintext.Length); } // Prepend the _keyBytes to the beginning of the plaintext diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs index 580823056..a5e8c4582 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs @@ -3,32 +3,23 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using Amazon.KeyManagementService; +using Amazon.Runtime; using GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms; +using GoDaddy.Asherah.AppEncryption.Kms; namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.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 : IKeyManagementClientFactory + public class KeyManagementClientFactoryStub(KeyManagementServiceOptions options) : AwsKmsClientFactory, IKeyManagementClientFactory { - private readonly KeyManagementServiceOptions _options; - private readonly Dictionary _clients = new Dictionary(); - - /// - /// Gets the dictionary of created clients by region. - /// - public IReadOnlyDictionary Clients => _clients; - - /// - /// Initializes a new instance of the class. - /// - /// The key management service options. - public KeyManagementClientFactoryStub(KeyManagementServiceOptions options) - { - _options = options; - } + private readonly Dictionary _clients = []; /// public IAmazonKeyManagementService CreateForRegion(string region) @@ -38,15 +29,17 @@ public IAmazonKeyManagementService CreateForRegion(string region) return existingClient; } - var regionKeyArn = _options.RegionKeyArns.FirstOrDefault(rka => rka.Region.Equals(region, StringComparison.OrdinalIgnoreCase)); - if (regionKeyArn == null) - { - throw new InvalidOperationException($"No key ARN found for region: {region}"); - } + 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/Extensions/Aws/Kms/KeyManagementServiceTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs index e78c3cc85..d2a3d3e06 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; @@ -7,9 +6,6 @@ using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers; using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; using GoDaddy.Asherah.Crypto.ExtensionMethods; -using GoDaddy.Asherah.Crypto.Keys; -using Microsoft.Extensions.Logging; -using Moq; using Xunit; namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.Aws.Kms @@ -22,25 +18,22 @@ public class KeyManagementServiceTests : IDisposable private const string UsWest1 = "us-west-1"; private const string ArnUsWest1 = "arn-us-west-1"; - private readonly KeyManagementServiceOptions _keyManagementServiceOptions; private readonly KeyManagementService _keyManagementService; - private readonly LoggerFactoryStub _loggerFactoryStub; - private readonly KeyManagementClientFactoryStub _clientFactoryStub; public KeyManagementServiceTests() { - _keyManagementServiceOptions = new KeyManagementServiceOptions + var keyManagementServiceOptions = new KeyManagementServiceOptions { - RegionKeyArns = new List - { + RegionKeyArns = + [ new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 }, new RegionKeyArn { Region = UsWest1, KeyArn = ArnUsWest1 } - } + ] }; - _loggerFactoryStub = new LoggerFactoryStub(); - _clientFactoryStub = new KeyManagementClientFactoryStub(_keyManagementServiceOptions); - _keyManagementService = new KeyManagementService(_keyManagementServiceOptions, _clientFactoryStub, _loggerFactoryStub); + var loggerFactoryStub = new LoggerFactoryStub(); + var clientFactoryStub = new KeyManagementClientFactoryStub(keyManagementServiceOptions); + _keyManagementService = new KeyManagementService(keyManagementServiceOptions, clientFactoryStub, loggerFactoryStub); } [Fact] diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs index b3be05eaf..34901445c 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.Extensions.Aws.Kms; using GoDaddy.Asherah.AppEncryption.Kms; +using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.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; @@ -569,7 +574,33 @@ public void TestEncryptKeyShouldThrowExceptionAndWipeBytes() [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); } } } diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerStub.cs index be47641a8..b11d3c5ed 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerStub.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/LoggerStub.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using Microsoft.Extensions.Logging; namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers diff --git a/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecord.cs b/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecord.cs index f7e37aa15..f16a14cda 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecord.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/DataRowRecord.cs @@ -1,4 +1,3 @@ -using System; using GoDaddy.Asherah.AppEncryption.Metastore; namespace GoDaddy.Asherah.AppEncryption.Envelope From 117cdd0e25b101ff2146339c0584d77366480749 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Tue, 9 Sep 2025 15:23:48 -0700 Subject: [PATCH 13/31] improved tests --- .../Aws/Kms/KeyManagementServiceTests.cs | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs index d2a3d3e06..dad6e06e9 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs @@ -15,25 +15,36 @@ public class KeyManagementServiceTests : IDisposable { private const string UsEast1 = "us-east-1"; private const string ArnUsEast1 = "arn-us-east-1"; - private const string UsWest1 = "us-west-1"; - private const string ArnUsWest1 = "arn-us-west-1"; + private const string UsWest2 = "us-west-2"; + private const string ArnUsWest2 = "arn-us-west-2"; - private readonly KeyManagementService _keyManagementService; + private readonly KeyManagementService _keyManagementServiceEast; + private readonly KeyManagementService _keyManagementServiceWest; public KeyManagementServiceTests() { - var keyManagementServiceOptions = new KeyManagementServiceOptions + var optionsEast = new KeyManagementServiceOptions { RegionKeyArns = [ new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 }, - new RegionKeyArn { Region = UsWest1, KeyArn = ArnUsWest1 } + new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 } + ] + }; + + var optionsWest = new KeyManagementServiceOptions + { + RegionKeyArns = + [ + new RegionKeyArn { Region = UsWest2, KeyArn = ArnUsWest2 }, + new RegionKeyArn { Region = UsEast1, KeyArn = ArnUsEast1 } ] }; var loggerFactoryStub = new LoggerFactoryStub(); - var clientFactoryStub = new KeyManagementClientFactoryStub(keyManagementServiceOptions); - _keyManagementService = new KeyManagementService(keyManagementServiceOptions, clientFactoryStub, loggerFactoryStub); + var clientFactoryStub = new KeyManagementClientFactoryStub(optionsEast); + _keyManagementServiceEast = new KeyManagementService(optionsEast, clientFactoryStub, loggerFactoryStub); + _keyManagementServiceWest = new KeyManagementService(optionsWest, clientFactoryStub, loggerFactoryStub); } [Fact] @@ -45,7 +56,7 @@ public async Task EncryptKeyAsync_ShouldEncryptKey() using var key = crypto.GenerateKey(keyCreationTime); // Act - var result = await _keyManagementService.EncryptKeyAsync(key); + var result = await _keyManagementServiceEast.EncryptKeyAsync(key); // Assert Assert.NotNull(result); @@ -91,7 +102,7 @@ public async Task EncryptKeyAsync_ShouldEncryptKey() Assert.NotNull(region); var regionValue = region!.AsValue().GetValue(); Assert.NotNull(regionValue); - Assert.True(regionValue == "us-east-1" || regionValue == "us-west-1"); + Assert.True(regionValue == "us-east-1" || regionValue == "us-west-2"); // Assert arn exists Assert.True(kekObject.ContainsKey("arn")); @@ -99,7 +110,7 @@ public async Task EncryptKeyAsync_ShouldEncryptKey() Assert.NotNull(arn); var arnValue = arn!.AsValue().GetValue(); Assert.NotNull(arnValue); - Assert.True(arnValue == "arn-us-east-1" || arnValue == "arn-us-west-1"); + Assert.True(arnValue == "arn-us-east-1" || arnValue == "arn-us-west-2"); // Assert encryptedKek exists and is not empty Assert.True(kekObject.ContainsKey("encryptedKek")); @@ -113,7 +124,7 @@ public async Task EncryptKeyAsync_ShouldEncryptKey() // 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-1", regions); + Assert.Contains("us-west-2", regions); } [Fact] @@ -125,16 +136,37 @@ public async Task DecryptKeyAsync_ShouldDecryptKey() using var originalKey = crypto.GenerateKey(keyCreationTime); // Act - var encryptedResult = await _keyManagementService.EncryptKeyAsync(originalKey); - var decryptedKey = await _keyManagementService.DecryptKeyAsync(encryptedResult, keyCreationTime, revoked: false); + 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 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()); } public void Dispose() { - _keyManagementService?.Dispose(); + _keyManagementServiceEast?.Dispose(); + _keyManagementServiceWest?.Dispose(); } } } From 2950d154a1bfa74ec4d988dc42ebec00817afb13 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Wed, 10 Sep 2025 10:02:41 -0700 Subject: [PATCH 14/31] finished implementation --- .../Envelope/EnvelopeEncryption.cs | 269 +++++++++++++++++- 1 file changed, 259 insertions(+), 10 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs index c0b8076e7..4a2cf7240 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs @@ -79,7 +79,7 @@ public byte[] DecryptDataRowRecord(byte[] dataRowRecord) /// public byte[] EncryptPayload(byte[] payload) { - throw new NotImplementedException("Implementation will be added later"); + return EncryptPayloadAsync(payload).GetAwaiter().GetResult(); } /// @@ -115,9 +115,182 @@ public async Task DecryptDataRowRecordAsync(byte[] dataRowRecord) } /// - public Task EncryptPayloadAsync(byte[] payload) + public async Task EncryptPayloadAsync(byte[] payload) { - throw new NotImplementedException("Implementation will be added later"); + var result = await WithIntermediateKeyForWrite(intermediateCryptoKey => _crypto.EnvelopeEncrypt( + payload, + intermediateCryptoKey, + new MetastoreKeyMeta { KeyId = _partition.IntermediateKeyId, Created = intermediateCryptoKey.GetCreated() })); + + var keyRecord = new DataRowRecordKey + { + Created = DateTimeOffset.UtcNow, + Key = Convert.ToBase64String(result.EncryptedKey), + ParentKeyMeta = result.UserState + }; + + var dataRowRecord = new DataRowRecord + { + Key = keyRecord, + Data = Convert.ToBase64String(result.CipherText) + }; + + return JsonSerializer.SerializeToUtf8Bytes(dataRowRecord, JsonOptions); + } + + /// + /// Executes a function with the intermediate key for write operations. + /// + /// the function to call using the decrypted intermediate key. + /// The result of the function execution. + private async Task WithIntermediateKeyForWrite(Func functionWithIntermediateKey) + { + // Try to get latest from cache. If not found or expired, get latest or create + CryptoKey intermediateKey = _intermediateKeyCache.GetLast(); + + if (intermediateKey == null || IsKeyExpiredOrRevoked(intermediateKey)) + { + intermediateKey = await GetLatestOrCreateIntermediateKey(); + + // Put the key into our cache if allowed + if (_cryptoPolicy.CanCacheIntermediateKeys()) + { + try + { + intermediateKey = _intermediateKeyCache.PutAndGetUsable(intermediateKey.GetCreated(), intermediateKey); + } + catch (Exception ex) + { + DisposeKey(intermediateKey, ex); + throw new AppEncryptionException("Unable to update cache for Intermediate Key", ex); + } + } + } + + return ApplyFunctionAndDisposeKey(intermediateKey, functionWithIntermediateKey); + } + + /// + /// Executes a function with the system key for write operations. + /// + /// the function to call using the decrypted system key. + /// The result of the function execution. + private async Task WithSystemKeyForWrite(Func functionWithSystemKey) + { + // Try to get latest from cache. If not found or expired, get latest or create + var systemKey = _systemKeyCache.GetLast(); + if (systemKey == null || IsKeyExpiredOrRevoked(systemKey)) + { + systemKey = await GetLatestOrCreateSystemKey(); + + // Put the key into our cache if allowed + if (_cryptoPolicy.CanCacheSystemKeys()) + { + try + { + var systemKeyMeta = new MetastoreKeyMeta { KeyId = _partition.SystemKeyId, Created = systemKey.GetCreated() }; + systemKey = _systemKeyCache.PutAndGetUsable(systemKeyMeta.Created, systemKey); + } + catch (Exception ex) + { + DisposeKey(systemKey, ex); + throw new AppEncryptionException("Unable to update cache for SystemKey", ex); + } + } + } + + return ApplyFunctionAndDisposeKey(systemKey, functionWithSystemKey); + } + + /// + /// Gets the latest intermediate key or creates a new one if needed. + /// + /// The latest or newly created intermediate key. + private async Task GetLatestOrCreateIntermediateKey() + { + // Phase 1: Try to load the latest intermediate key + var (found, newestIntermediateKeyRecord) = await _metastore.TryLoadLatestAsync(_partition.IntermediateKeyId); + + if (found) + { + // If the key we just got back isn't expired, then just use it + if (!IsKeyExpiredOrRevoked(newestIntermediateKeyRecord)) + { + try + { + if (newestIntermediateKeyRecord.ParentKeyMeta == null) + { + throw new MetadataMissingException("Could not find parentKeyMeta (SK) for intermediateKey"); + } + + return await WithExistingSystemKey( + newestIntermediateKeyRecord.ParentKeyMeta, + true, + key => DecryptKey(newestIntermediateKeyRecord, key)); + } + catch (MetadataMissingException ex) + { + _logger?.LogWarning( + ex, + "The SK for the IK ({KeyId}, {Created}) is missing or in an invalid state. Will create new IK instead.", + _partition.IntermediateKeyId, + newestIntermediateKeyRecord.Created); + } + } + + // If we're here, we have an expired key and will create a new one + // Fall through to Phase 2: create new key + } + + // Phase 2: Create new intermediate key + var intermediateKeyCreated = _cryptoPolicy.TruncateToIntermediateKeyPrecision(DateTime.UtcNow); + var intermediateKey = _crypto.GenerateKey(intermediateKeyCreated); + + try + { + var newIntermediateKeyRecord = await WithSystemKeyForWrite(systemCryptoKey => + new KeyRecord( + intermediateKey.GetCreated(), + Convert.ToBase64String(_crypto.EncryptKey(intermediateKey, systemCryptoKey)), + false, + new MetastoreKeyMeta { KeyId = _partition.SystemKeyId, Created = systemCryptoKey.GetCreated() })); + + if (await _metastore.StoreAsync(_partition.IntermediateKeyId, newIntermediateKeyRecord.Created, newIntermediateKeyRecord)) + { + return intermediateKey; + } + else + { + // Duplicate detected - dispose the key we created + DisposeKey(intermediateKey, null); + } + } + catch (Exception ex) + { + DisposeKey(intermediateKey, ex); + throw new AppEncryptionException("Unable to create new Intermediate Key", ex); + } + + // If we're here, storing failed (duplicate detected). Load the actual latest key + var (retryFound, actualLatestIntermediateKeyRecord) = await _metastore.TryLoadLatestAsync(_partition.IntermediateKeyId); + + if (retryFound) + { + if (actualLatestIntermediateKeyRecord.ParentKeyMeta == null) + { + throw new MetadataMissingException("Could not find parentKeyMeta (SK) for intermediateKey"); + } + + // Decrypt and return the actual latest key + return await WithExistingSystemKey( + actualLatestIntermediateKeyRecord.ParentKeyMeta, + true, + key => DecryptKey(actualLatestIntermediateKeyRecord, key)); + } + else + { + throw new AppEncryptionException("IntermediateKey not present after LoadLatestKeyRecord retry"); + } } /// @@ -179,7 +352,7 @@ private async Task GetIntermediateKey(IKeyMeta intermediateKeyMeta) return await WithExistingSystemKey( intermediateKeyRecord.ParentKeyMeta, false, // treatExpiredAsMissing = false (allow expired keys) - systemKey => Task.FromResult(DecryptKey(intermediateKeyRecord, systemKey))); + systemKey => DecryptKey(intermediateKeyRecord, systemKey)); } /// @@ -197,10 +370,10 @@ private async Task GetIntermediateKey(IKeyMeta intermediateKeyMeta) /// If the system key is not found, or if its expired/revoked and /// is true. private async Task WithExistingSystemKey( - IKeyMeta systemKeyMeta, bool treatExpiredAsMissing, Func> functionWithSystemKey) + IKeyMeta systemKeyMeta, bool treatExpiredAsMissing, Func functionWithSystemKey) { // Get from cache or lookup previously used key - CryptoKey systemKey = _systemKeyCache.Get(systemKeyMeta.Created); + var systemKey = _systemKeyCache.Get(systemKeyMeta.Created); if (systemKey == null) { @@ -230,7 +403,7 @@ private async Task WithExistingSystemKey( } } - return await ApplyFunctionAndDisposeKey(systemKey, functionWithSystemKey); + return ApplyFunctionAndDisposeKey(systemKey, functionWithSystemKey); } /// @@ -250,12 +423,78 @@ private async Task GetSystemKey(IKeyMeta systemKeyMeta) throw new MetadataMissingException($"Could not find EnvelopeKeyRecord with keyId = {systemKeyMeta.KeyId}, created = {systemKeyMeta.Created}"); } - return _keyManagementService.DecryptKey( + return await _keyManagementService.DecryptKeyAsync( Convert.FromBase64String(systemKeyRecord.Key), systemKeyRecord.Created, systemKeyRecord.Revoked ?? false); } + /// + /// Gets the latest system key or creates a new one if needed. + /// + /// The latest or newly created system key. + private async Task GetLatestOrCreateSystemKey() + { + // Phase 1: Load existing key + var (found, newestSystemKeyRecord) = await _metastore.TryLoadLatestAsync(_partition.SystemKeyId); + + if (found) + { + // If the key we just got back isn't expired, then just use it + if (!IsKeyExpiredOrRevoked(newestSystemKeyRecord)) + { + return await _keyManagementService.DecryptKeyAsync( + Convert.FromBase64String(newestSystemKeyRecord.Key), + newestSystemKeyRecord.Created, + newestSystemKeyRecord.Revoked ?? false); + } + + // If we're here then we're doing inline rotation and have an expired key. + // Fall through as if we didn't have the key + } + + // Phase 2: Create new key + var systemKeyCreated = _cryptoPolicy.TruncateToSystemKeyPrecision(DateTimeOffset.UtcNow); + var systemKey = _crypto.GenerateKey(systemKeyCreated); + try + { + var newSystemKeyRecord = new KeyRecord( + systemKey.GetCreated(), + Convert.ToBase64String(await _keyManagementService.EncryptKeyAsync(systemKey)), + false, + null); // No parent key for system keys + + if (await _metastore.StoreAsync(_partition.SystemKeyId, newSystemKeyRecord.Created, newSystemKeyRecord)) + { + return systemKey; + } + else + { + DisposeKey(systemKey, null); + } + } + catch (Exception ex) + { + DisposeKey(systemKey, ex); + throw new AppEncryptionException("Unable to store new System Key", ex); + } + + // Phase 3: Retry logic - if storing failed, load the latest key + var (retryFound, actualLatestSystemKeyRecord) = await _metastore.TryLoadLatestAsync(_partition.SystemKeyId); + + if (retryFound) + { + return await _keyManagementService.DecryptKeyAsync( + Convert.FromBase64String(actualLatestSystemKeyRecord.Key), + actualLatestSystemKeyRecord.Created, + actualLatestSystemKeyRecord.Revoked ?? false); + } + else + { + throw new AppEncryptionException("SystemKey not present after LoadLatestKeyRecord retry"); + } + } + /// /// Decrypts the 's encrypted key using the provided key. /// @@ -273,6 +512,16 @@ private CryptoKey DecryptKey(IKeyRecord keyRecord, CryptoKey keyEncryptionKey) keyRecord.Revoked ?? false); } + /// + /// Checks if a key record is expired or revoked. + /// + /// The key record to check. + /// True if the key record is expired or revoked, false otherwise. + private bool IsKeyExpiredOrRevoked(IKeyRecord keyRecord) + { + return _cryptoPolicy.IsKeyExpired(keyRecord.Created) || (keyRecord.Revoked ?? false); + } + /// /// Checks if a key is expired or revoked. /// @@ -289,11 +538,11 @@ private bool IsKeyExpiredOrRevoked(CryptoKey cryptoKey) /// The crypto key to use. /// The function to execute with the key. /// The result of the function execution. - private static async Task ApplyFunctionAndDisposeKey(CryptoKey key, Func> functionWithKey) + private static T ApplyFunctionAndDisposeKey(CryptoKey key, Func functionWithKey) { try { - return await functionWithKey(key); + return functionWithKey(key); } catch (Exception ex) { From 3a5b0c75f5f21834346ea2e8e44c4ef924732eee Mon Sep 17 00:00:00 2001 From: chief-micco Date: Wed, 10 Sep 2025 11:39:52 -0700 Subject: [PATCH 15/31] change stub to work based on old impl logic --- .../Aws/Kms/AwsKeyManagementStub.cs | 59 +++++++++---------- .../Kms/AwsKeyManagementServiceImplTest.cs | 2 + 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs index 4b66ddd52..800421f29 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs @@ -12,22 +12,13 @@ namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.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 : IAmazonKeyManagementService + public class AwsKeyManagementStub(string keyArn) : IAmazonKeyManagementService { - private readonly string _keyArn; - private readonly byte[] _keyBytes; - - /// - /// Initializes a new instance of the class. - /// - /// The key ARN for this stub. - public AwsKeyManagementStub(string keyArn) - { - _keyArn = keyArn; - _keyBytes = System.Text.Encoding.UTF8.GetBytes(_keyArn); - } - public Amazon.Runtime.IClientConfig Config => throw new NotImplementedException(); public IKeyManagementServicePaginatorFactory Paginators => throw new NotImplementedException(); @@ -81,23 +72,25 @@ public Task DecryptAsync(DecryptRequest request, CancellationTo 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) + if (ciphertext.Length < keyBytes.Length) { - throw new ArgumentException($"Ciphertext too short. Expected at least {_keyBytes.Length} bytes, got {ciphertext.Length}"); + throw new AmazonServiceException($"Ciphertext too short. Expected at least {keyBytes.Length} bytes, got {ciphertext.Length}"); } - for (int i = 0; i < _keyBytes.Length; i++) + for (var i = 0; i < keyBytes.Length; i++) { - if (ciphertext[i] != _keyBytes[i]) + if (ciphertext[i] != keyBytes[i]) { - throw new ArgumentException($"Ciphertext key bytes don't match expected _keyBytes at position {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); + 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 @@ -196,9 +189,9 @@ public Task EnableKeyRotationAsync(string keyId, Canc public Task EncryptAsync(EncryptRequest request, CancellationToken cancellationToken = default) { // Validate the KeyId matches our stored KeyArn - if (request.KeyId != _keyArn) + if (request.KeyId != keyArn) { - throw new ArgumentException($"KeyId '{request.KeyId}' does not match expected KeyArn '{_keyArn}'"); + throw new ArgumentException($"KeyId '{request.KeyId}' does not match expected KeyArn '{keyArn}'"); } // Read the plaintext from the request @@ -209,16 +202,18 @@ public Task EncryptAsync(EncryptRequest request, CancellationTo 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); + 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 + KeyId = keyArn }; return Task.FromResult(response); } @@ -226,20 +221,20 @@ public Task EncryptAsync(EncryptRequest request, CancellationTo public Task GenerateDataKeyAsync(GenerateDataKeyRequest request, CancellationToken cancellationToken = default) { // Validate the KeyId matches our stored KeyArn - if (request.KeyId != _keyArn) + if (request.KeyId != keyArn) { - throw new ArgumentException($"KeyId '{request.KeyId}' does not match expected KeyArn '{_keyArn}'"); + throw new ArgumentException($"KeyId '{request.KeyId}' does not match expected KeyArn '{keyArn}'"); } // Generate fake data based on the _keyArn to make it unique per ARN - byte[] fakePlaintext = GenerateFakeDataFromArn(_keyArn, "plaintext"); - byte[] fakeCiphertext = GenerateFakeDataFromArn(_keyArn, "ciphertext"); + 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 + KeyId = keyArn }; return Task.FromResult(response); } diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs index 34901445c..ce3afe131 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs @@ -601,6 +601,8 @@ public void TestEncryptAndDecryptKeyWithStub() // Assert Assert.NotNull(decryptedKey); + Assert.Equal(originalKey.WithKey(keyBytes => keyBytes), decryptedKey.WithKey(keyBytes => keyBytes)); + Assert.Equal(originalKey.GetCreated(), decryptedKey.GetCreated()); } } } From f6fa226547fcfdbfcec35509cbfa5d934383e22c Mon Sep 17 00:00:00 2001 From: chief-micco Date: Thu, 11 Sep 2025 13:26:24 -0700 Subject: [PATCH 16/31] tests for the new EnvelopeEncryption in progress --- .../Envelope/EnvelopeEncryptionTests.cs | 320 ++++++++++++++++++ ...mplTest.cs => InMemoryKeyMetastoreTest.cs} | 63 ++-- .../Envelope/EnvelopeEncryption.cs | 54 +-- ...tastoreImpl.cs => InMemoryKeyMetastore.cs} | 42 +-- .../AppEncryption/Metastore/KeyMeta.cs | 3 - .../AppEncryption/Metastore/KeyRecord.cs | 3 - .../Serialization/InterfaceConverter.cs | 4 +- 7 files changed, 407 insertions(+), 82 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs rename csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/{InMemoryKeyMetastoreImplTest.cs => InMemoryKeyMetastoreTest.cs} (53%) rename csharp/AppEncryption/AppEncryption/Metastore/{InMemoryKeyMetastoreImpl.cs => InMemoryKeyMetastore.cs} (75%) diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs new file mode 100644 index 000000000..66278660b --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs @@ -0,0 +1,320 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Envelope; +using GoDaddy.Asherah.AppEncryption.Exceptions; +using GoDaddy.Asherah.AppEncryption.Kms; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers.Dummy; +using GoDaddy.Asherah.Crypto; +using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; +using GoDaddy.Asherah.Crypto.Keys; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Envelope; + +[ExcludeFromCodeCoverage] +public class EnvelopeEncryptionTests +{ + private readonly DefaultPartition _partition = new("defaultPartition", "testService", "testProduct"); + + private EnvelopeEncryption NewEnvelopeEncryption( + CryptoPolicy cryptoPolicy = null, + IKeyManagementService keyManagementService = null, + IKeyMetastore metastore = null, + Partition partition = null) + { + metastore ??= new InMemoryKeyMetastore(); + var loggerFactory = new TestHelpers.LoggerFactoryStub(); + var logger = loggerFactory.CreateLogger("EnvelopeEncryptionTests"); + keyManagementService ??= new DummyKeyManagementService(); + var crypto = new BouncyAes256GcmCrypto(); + partition ??= _partition; + + cryptoPolicy ??= BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(true) + .WithCanCacheSystemKeys(true) + .WithCanCacheSessions(false) + .Build(); + + var systemKeyCache = new SecureCryptoKeyDictionary(cryptoPolicy.GetRevokeCheckPeriodMillis()); + var intermediateKeyCache = new SecureCryptoKeyDictionary(cryptoPolicy.GetRevokeCheckPeriodMillis()); + + return new EnvelopeEncryption( + partition, + metastore, + keyManagementService, + crypto, + cryptoPolicy, + systemKeyCache, + intermediateKeyCache, + logger); + } + + [Fact] + public void EncryptDecrypt_WithDefaults_Sync() + { + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + using var envelopeEncryption = NewEnvelopeEncryption(); + var dataRowRecordBytes = envelopeEncryption.EncryptPayload(inputBytes); + + ValidateDataRowRecordJson(dataRowRecordBytes); + + var decryptedBytes = envelopeEncryption.DecryptDataRowRecord(dataRowRecordBytes); + var outputValue = System.Text.Encoding.UTF8.GetString(decryptedBytes); + + Assert.Equal(inputValue, outputValue); + } + + [Fact] + public async Task EncryptDecrypt_WithDefaults() + { + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + using var envelopeEncryption = NewEnvelopeEncryption(); + var dataRowRecordBytes = await envelopeEncryption.EncryptPayloadAsync(inputBytes); + + ValidateDataRowRecordJson(dataRowRecordBytes); + + var decryptedBytes = await envelopeEncryption.DecryptDataRowRecordAsync(dataRowRecordBytes); + var outputValue = System.Text.Encoding.UTF8.GetString(decryptedBytes); + + Assert.Equal(inputValue, outputValue); + } + + [Fact] + public async Task EncryptDecrypt_MultipleTimes_WithDefaults() + { + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + const string inputValue2 = "Lorem ipsum dolor sit amet, consectetur adipiscing elit"; + var inputBytes2 = System.Text.Encoding.UTF8.GetBytes(inputValue2); + + using var envelopeEncryption = NewEnvelopeEncryption(); + var dataRowRecordBytes = await envelopeEncryption.EncryptPayloadAsync(inputBytes); + var dataRowRecordBytes2 = await envelopeEncryption.EncryptPayloadAsync(inputBytes2); + + ValidateDataRowRecordJson(dataRowRecordBytes); + ValidateDataRowRecordJson(dataRowRecordBytes2); + + var decryptedBytes = await envelopeEncryption.DecryptDataRowRecordAsync(dataRowRecordBytes); + var outputValue = System.Text.Encoding.UTF8.GetString(decryptedBytes); + + Assert.Equal(inputValue, outputValue); + + var decryptAgainBytes = await envelopeEncryption.DecryptDataRowRecordAsync(dataRowRecordBytes); + var outputValueAgain = System.Text.Encoding.UTF8.GetString(decryptAgainBytes); + + Assert.Equal(inputValue, outputValueAgain); + + var decryptedBytes2 = await envelopeEncryption.DecryptDataRowRecordAsync(dataRowRecordBytes2); + var outputValue2 = System.Text.Encoding.UTF8.GetString(decryptedBytes2); + + Assert.Equal(inputValue2, outputValue2); + } + + [Fact] + public async Task EncryptDecrypt_WithDifferentInstances() + { + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(); + + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + using var envelopeEncryption = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore); + using var envelopeEncryption2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore); + + var dataRowRecordBytes = await envelopeEncryption.EncryptPayloadAsync(inputBytes); + var dataRowRecordBytes2 = await envelopeEncryption2.EncryptPayloadAsync(inputBytes); + + ValidateDataRowRecordJson(dataRowRecordBytes); + + var decryptedBytes = await envelopeEncryption2.DecryptDataRowRecordAsync(dataRowRecordBytes); + var outputValue = System.Text.Encoding.UTF8.GetString(decryptedBytes); + + Assert.Equal(inputValue, outputValue); + + var decryptedBytes2 = await envelopeEncryption.DecryptDataRowRecordAsync(dataRowRecordBytes2); + var outputValue2 = System.Text.Encoding.UTF8.GetString(decryptedBytes2); + + Assert.Equal(inputValue, outputValue2); + } + + [Fact] + public async Task Decrypt_Throws_When_IntermediateKey_Cannot_Be_Found() + { + var keyManagementService = new DummyKeyManagementService(); + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(); + + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + using var envelopeEncryption = NewEnvelopeEncryption(cryptoPolicy, keyManagementService); + + // new instance will be using an empty metastore + using var envelopeEncryption2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService); + + var dataRowRecordBytes = await envelopeEncryption.EncryptPayloadAsync(inputBytes); + + await Assert.ThrowsAsync(async () => + { + await envelopeEncryption2.DecryptDataRowRecordAsync(dataRowRecordBytes); + }); + + } + + + [Theory] + [MemberData(nameof(GetCryptoPolicies))] + public async Task EncryptDecrypt_WithVariousCryptoPolicies(CryptoPolicy cryptoPolicy) + { + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + using var envelopeEncryption = NewEnvelopeEncryption(cryptoPolicy); + var dataRowRecordBytes = await envelopeEncryption.EncryptPayloadAsync(inputBytes); + + ValidateDataRowRecordJson(dataRowRecordBytes); + + var decryptedBytes = await envelopeEncryption.DecryptDataRowRecordAsync(dataRowRecordBytes); + var outputValue = System.Text.Encoding.UTF8.GetString(decryptedBytes); + + Assert.Equal(inputValue, outputValue); + + var decryptAgainBytes = await envelopeEncryption.DecryptDataRowRecordAsync(dataRowRecordBytes); + var outputValueAgain = System.Text.Encoding.UTF8.GetString(decryptAgainBytes); + + Assert.Equal(inputValue, outputValueAgain); + } + + public static TheoryData GetCryptoPolicies() + { + return new TheoryData( + BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(), + BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) + .WithCanCacheSystemKeys(true) + .WithCanCacheSessions(false) + .Build(), + BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(true) + .WithCanCacheSystemKeys(true) + .WithCanCacheSessions(false) + .Build()); + } + + [Fact] + public async Task Encrypt_Uses_Partitions() + { + var partition1 = new DefaultPartition("partition1", "service", "product"); + var partition2 = new DefaultPartition("partition2", "service", "product"); + + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + using var envelopeEncryption1 = NewEnvelopeEncryption(partition: partition1); + using var envelopeEncryption2 = NewEnvelopeEncryption(partition: partition2); + + var dataRowRecordBytes1 = await envelopeEncryption1.EncryptPayloadAsync(inputBytes); + var dataRowRecordBytes2 = await envelopeEncryption2.EncryptPayloadAsync(inputBytes); + + Assert.NotEqual(dataRowRecordBytes1, dataRowRecordBytes2); + } + + [Theory] + [InlineData("")] + [InlineData("Not a JSON string")] + [InlineData("null")] // Missing required fields + [InlineData("{\"Invalid\":\"Missing required fields\"}", typeof(MetadataMissingException))] + [InlineData("{\"Key\":null,\"Data\":\"ValidBase64ButKeyIsNull\"}", typeof(MetadataMissingException))] + [InlineData("{\"Key\":{\"Created\":1752685310,\"Key\":\"ParentKeyMetaIsMissing\"},\"Data\":\"SomeData\"}", typeof(MetadataMissingException))] + [InlineData("{\"Key\":{\"Created\":1752685310,\"Key\":\"ParentKeyMetaIsNull\",\"ParentKeyMeta\":null},\"Data\":\"SomeData\"}", typeof(MetadataMissingException))] + [InlineData("{\"Key\":{\"Created\":1752685310,\"Key\":\"ParentKeyKeyIdIsMissing\",\"ParentKeyMeta\":{\"Created\":1752501780}},\"Data\":\"SomeData\"}", typeof(MetadataMissingException))] + [InlineData("{\"Key\":{\"Created\":1752685310,\"Key\":\"ParentKeyKeyIdIsNull\",\"ParentKeyMeta\":{\"KeyId\":null,\"Created\":1752501780}},\"Data\":\"SomeData\"}", typeof(MetadataMissingException))] + [InlineData("{\"Key\":{\"Created\":1752685310,\"Key\":\"ParentKeyKeyIdNotValid\",\"ParentKeyMeta\":{\"KeyId\":\"not-valid-key\",\"Created\":1752501780}},\"Data\":\"SomeData\"}", typeof(MetadataMissingException))] + public async Task Bad_DataRowRecord_Throws(string dataRowRecordString, Type exceptionType = null) + { + var badDataRowRecordBytes = System.Text.Encoding.UTF8.GetBytes(dataRowRecordString); + + using var envelopeEncryption = NewEnvelopeEncryption(); + + exceptionType ??= typeof(ArgumentException); + + await Assert.ThrowsAsync(exceptionType, async () => + { + await envelopeEncryption.DecryptDataRowRecordAsync(badDataRowRecordBytes); + }); + } + + private static void ValidateDataRowRecordJson(byte[] dataRowRecordBytes) + { + // Deserialize into JsonNode and validate structure matches this format: + /* + { + "Key": { + "Created": 1752685310, + "Key": "base64-encryptedDataRowKeyByteArray", + "ParentKeyMeta": { + "KeyId": "_IK_widgets_dotnet-guild-tools_Human-Resources_us-west-2", + "Created": 1752501780 + } + }, + "Data": "base64-encryptedDataByteArray" + } + */ + var dataRowObject = JsonNode.Parse(dataRowRecordBytes); + Assert.NotNull(dataRowObject); + Assert.NotNull(dataRowObject["Key"]); + Assert.Equal(JsonValueKind.Object, dataRowObject["Key"]?.GetValueKind()); + + Assert.NotNull(dataRowObject["Data"]); + Assert.Equal(JsonValueKind.String, dataRowObject["Data"]?.GetValueKind()); + + Assert.NotNull(dataRowObject["Key"]?["Created"]); + Assert.Equal(JsonValueKind.Number, dataRowObject["Key"]?["Created"]?.GetValueKind()); + + Assert.NotNull(dataRowObject["Key"]?["Key"]); + Assert.Equal(JsonValueKind.String, dataRowObject["Key"]?["Key"]?.GetValueKind()); + + Assert.NotNull(dataRowObject["Key"]?["ParentKeyMeta"]); + Assert.Equal(JsonValueKind.Object, dataRowObject["Key"]?["ParentKeyMeta"]?.GetValueKind()); + + Assert.NotNull(dataRowObject["Key"]?["ParentKeyMeta"]?["KeyId"]); + Assert.Equal(JsonValueKind.String, dataRowObject["Key"]?["ParentKeyMeta"]?["KeyId"]?.GetValueKind()); + + Assert.NotNull(dataRowObject["Key"]?["ParentKeyMeta"]?["Created"]); + Assert.Equal(JsonValueKind.Number, dataRowObject["Key"]?["ParentKeyMeta"]?["Created"]?.GetValueKind()); + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreTest.cs similarity index 53% rename from csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs rename to csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreTest.cs index 130eb49cb..868f33449 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreTest.cs @@ -1,29 +1,26 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Metastore; using Xunit; namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Metastore { - public class InMemoryKeyMetastoreImplTest : IDisposable + [ExcludeFromCodeCoverage] + public class InMemoryKeyMetastoreTest : IDisposable { - private readonly InMemoryKeyMetastoreImpl inMemoryKeyMetastoreImpl; - - public InMemoryKeyMetastoreImplTest() - { - inMemoryKeyMetastoreImpl = new InMemoryKeyMetastoreImpl(); - } + private readonly InMemoryKeyMetastore _inMemoryKeyMetastore = new(); [Fact] private async Task TestTryLoadAndStoreWithValidKey() { const string keyId = "ThisIsMyKey"; - DateTimeOffset created = DateTimeOffset.UtcNow; + var created = DateTimeOffset.UtcNow; var keyRecord = new KeyRecord(created, "test-key-data", false); - await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); + await _inMemoryKeyMetastore.StoreAsync(keyId, created, keyRecord); - var (success, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadAsync(keyId, created); + var (success, actualKeyRecord) = await _inMemoryKeyMetastore.TryLoadAsync(keyId, created); Assert.True(success); Assert.Equal(keyRecord, actualKeyRecord); @@ -33,12 +30,12 @@ private async Task TestTryLoadAndStoreWithValidKey() private async Task TestTryLoadAndStoreWithInvalidKey() { const string keyId = "ThisIsMyKey"; - DateTimeOffset created = DateTimeOffset.UtcNow; + var created = DateTimeOffset.UtcNow; var keyRecord = new KeyRecord(created, "test-key-data", false); - await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); + await _inMemoryKeyMetastore.StoreAsync(keyId, created, keyRecord); - var (success, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadAsync("some non-existent key", created); + var (success, actualKeyRecord) = await _inMemoryKeyMetastore.TryLoadAsync("some non-existent key", created); Assert.False(success); Assert.Null(actualKeyRecord); @@ -48,24 +45,24 @@ private async Task TestTryLoadAndStoreWithInvalidKey() private async Task TestTryLoadLatestMultipleCreatedAndValuesForKeyIdShouldReturnLatest() { const string keyId = "ThisIsMyKey"; - DateTimeOffset created = DateTimeOffset.UtcNow; + var created = DateTimeOffset.UtcNow; var keyRecord = new KeyRecord(created, "test-key-data", false); - await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); + await _inMemoryKeyMetastore.StoreAsync(keyId, created, keyRecord); - DateTimeOffset createdOneHourLater = created.AddHours(1); + var createdOneHourLater = created.AddHours(1); var keyRecordOneHourLater = new KeyRecord(createdOneHourLater, "test-key-data-hour", false); - await inMemoryKeyMetastoreImpl.StoreAsync(keyId, createdOneHourLater, keyRecordOneHourLater); + await _inMemoryKeyMetastore.StoreAsync(keyId, createdOneHourLater, keyRecordOneHourLater); - DateTimeOffset createdOneDayLater = created.AddDays(1); + var createdOneDayLater = created.AddDays(1); var keyRecordOneDayLater = new KeyRecord(createdOneDayLater, "test-key-data-day", false); - await inMemoryKeyMetastoreImpl.StoreAsync(keyId, createdOneDayLater, keyRecordOneDayLater); + await _inMemoryKeyMetastore.StoreAsync(keyId, createdOneDayLater, keyRecordOneDayLater); - DateTimeOffset createdOneWeekEarlier = created.AddDays(-7); + var createdOneWeekEarlier = created.AddDays(-7); var keyRecordOneWeekEarlier = new KeyRecord(createdOneWeekEarlier, "test-key-data-week", false); - await inMemoryKeyMetastoreImpl.StoreAsync(keyId, createdOneWeekEarlier, keyRecordOneWeekEarlier); + await _inMemoryKeyMetastore.StoreAsync(keyId, createdOneWeekEarlier, keyRecordOneWeekEarlier); - var (success, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadLatestAsync(keyId); + var (success, actualKeyRecord) = await _inMemoryKeyMetastore.TryLoadLatestAsync(keyId); Assert.True(success); Assert.Equal(keyRecordOneDayLater, actualKeyRecord); @@ -75,12 +72,12 @@ private async Task TestTryLoadLatestMultipleCreatedAndValuesForKeyIdShouldReturn private async Task TestTryLoadLatestNonExistentKeyIdShouldReturnFalse() { const string keyId = "ThisIsMyKey"; - DateTimeOffset created = DateTimeOffset.UtcNow; + var created = DateTimeOffset.UtcNow; var keyRecord = new KeyRecord(created, "test-key-data", false); - await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); + await _inMemoryKeyMetastore.StoreAsync(keyId, created, keyRecord); - var (success, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadLatestAsync("some non-existent key"); + var (success, actualKeyRecord) = await _inMemoryKeyMetastore.TryLoadLatestAsync("some non-existent key"); Assert.False(success); Assert.Null(actualKeyRecord); @@ -90,26 +87,26 @@ private async Task TestTryLoadLatestNonExistentKeyIdShouldReturnFalse() private async Task TestStoreWithDuplicateKeyShouldReturnFalse() { const string keyId = "ThisIsMyKey"; - DateTimeOffset created = DateTimeOffset.UtcNow; + var created = DateTimeOffset.UtcNow; var keyRecord = new KeyRecord(created, "test-key-data", false); - Assert.True(await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord)); - Assert.False(await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord)); + Assert.True(await _inMemoryKeyMetastore.StoreAsync(keyId, created, keyRecord)); + Assert.False(await _inMemoryKeyMetastore.StoreAsync(keyId, created, keyRecord)); } [Fact] private async Task TestStoreWithIntermediateKeyRecord() { const string keyId = "ThisIsMyKey"; - DateTimeOffset created = DateTimeOffset.UtcNow; + var created = DateTimeOffset.UtcNow; var parentKeyMeta = new KeyMeta { KeyId = "parentKey", Created = created.AddDays(-1) }; var keyRecord = new KeyRecord(created, "test-key-data-parent", false, parentKeyMeta); - bool success = await inMemoryKeyMetastoreImpl.StoreAsync(keyId, created, keyRecord); + var success = await _inMemoryKeyMetastore.StoreAsync(keyId, created, keyRecord); Assert.True(success); - var (loadSuccess, actualKeyRecord) = await inMemoryKeyMetastoreImpl.TryLoadAsync(keyId, created); + var (loadSuccess, actualKeyRecord) = await _inMemoryKeyMetastore.TryLoadAsync(keyId, created); Assert.True(loadSuccess); Assert.Equal(keyRecord, actualKeyRecord); } @@ -117,7 +114,7 @@ private async Task TestStoreWithIntermediateKeyRecord() [Fact] private void TestGetKeySuffixReturnsEmptyString() { - string keySuffix = inMemoryKeyMetastoreImpl.GetKeySuffix(); + var keySuffix = _inMemoryKeyMetastore.GetKeySuffix(); Assert.Equal(string.Empty, keySuffix); } @@ -126,7 +123,7 @@ private void TestGetKeySuffixReturnsEmptyString() /// public void Dispose() { - inMemoryKeyMetastoreImpl?.Dispose(); + _inMemoryKeyMetastore?.Dispose(); } } } diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs index 4a2cf7240..e1a62725d 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs @@ -1,5 +1,6 @@ using System; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Exceptions; using GoDaddy.Asherah.AppEncryption.Metastore; @@ -21,12 +22,24 @@ namespace GoDaddy.Asherah.AppEncryption.Envelope /// internal sealed class EnvelopeEncryption : IEnvelopeEncryption { - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions + private static readonly JsonSerializerOptions JsonReadOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, Converters = { new InterfaceConverter(), - new InterfaceConverter() + new InterfaceConverter(), + new UnixTimestampDateTimeOffsetConverter() + } + }; + + private static readonly JsonSerializerOptions JsonWriteOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = + { + new UnixTimestampDateTimeOffsetConverter() } }; @@ -43,30 +56,30 @@ internal sealed class EnvelopeEncryption : IEnvelopeEncryption /// Initializes a new instance of the class. /// /// The partition for this envelope encryption instance. + /// The metastore for storing and retrieving keys. + /// Service for key management operations. /// The crypto implementation for envelope operations. /// Policy that dictates crypto behaviors. - /// The metastore for storing and retrieving keys. /// Cache for system keys. /// Cache for intermediate keys. - /// Service for key management operations. /// The logger implementation to use. public EnvelopeEncryption( Partition partition, + IKeyMetastore metastore, + IKeyManagementService keyManagementService, AeadEnvelopeCrypto crypto, CryptoPolicy cryptoPolicy, - IKeyMetastore metastore, SecureCryptoKeyDictionary systemKeyCache, SecureCryptoKeyDictionary intermediateKeyCache, - IKeyManagementService keyManagementService, ILogger logger) { _partition = partition; + _metastore = metastore; + _keyManagementService = keyManagementService; _crypto = crypto; _cryptoPolicy = cryptoPolicy; - _metastore = metastore; _systemKeyCache = systemKeyCache; _intermediateKeyCache = intermediateKeyCache; - _keyManagementService = keyManagementService; _logger = logger; } @@ -85,9 +98,9 @@ public byte[] EncryptPayload(byte[] payload) /// public async Task DecryptDataRowRecordAsync(byte[] dataRowRecord) { - DataRowRecord dataRowRecordModel = DeserializeDataRowRecord(dataRowRecord); + var dataRowRecordModel = DeserializeDataRowRecord(dataRowRecord); - if (dataRowRecordModel.Key?.ParentKeyMeta == null) + if (dataRowRecordModel.Key?.ParentKeyMeta?.KeyId == null) { throw new MetadataMissingException("Could not find parentKeyMeta {IK} for dataRowKey"); } @@ -99,10 +112,10 @@ public async Task DecryptDataRowRecordAsync(byte[] dataRowRecord) // the Data property is a base64 encoded string containing the encrypted payload // the Key property from the DataRowRecord.Key is a base64 encoded string containing the encrypted key - byte[] payloadEncrypted = Convert.FromBase64String(dataRowRecordModel.Data); - byte[] encryptedKey = Convert.FromBase64String(dataRowRecordModel.Key.Key); + var payloadEncrypted = Convert.FromBase64String(dataRowRecordModel.Data); + var encryptedKey = Convert.FromBase64String(dataRowRecordModel.Key.Key); - byte[] decryptedPayload = await WithIntermediateKeyForRead( + var decryptedPayload = await WithIntermediateKeyForRead( dataRowRecordModel.Key.ParentKeyMeta, intermediateCryptoKey => _crypto.EnvelopeDecrypt( @@ -117,7 +130,7 @@ public async Task DecryptDataRowRecordAsync(byte[] dataRowRecord) /// public async Task EncryptPayloadAsync(byte[] payload) { - var result = await WithIntermediateKeyForWrite(intermediateCryptoKey => _crypto.EnvelopeEncrypt( + var result = await WithIntermediateKeyForWrite(intermediateCryptoKey => _crypto.EnvelopeEncrypt( payload, intermediateCryptoKey, new MetastoreKeyMeta { KeyId = _partition.IntermediateKeyId, Created = intermediateCryptoKey.GetCreated() })); @@ -135,7 +148,7 @@ public async Task EncryptPayloadAsync(byte[] payload) Data = Convert.ToBase64String(result.CipherText) }; - return JsonSerializer.SerializeToUtf8Bytes(dataRowRecord, JsonOptions); + return JsonSerializer.SerializeToUtf8Bytes(dataRowRecord, JsonWriteOptions); } /// @@ -146,7 +159,7 @@ public async Task EncryptPayloadAsync(byte[] payload) private async Task WithIntermediateKeyForWrite(Func functionWithIntermediateKey) { // Try to get latest from cache. If not found or expired, get latest or create - CryptoKey intermediateKey = _intermediateKeyCache.GetLast(); + var intermediateKey = _intermediateKeyCache.GetLast(); if (intermediateKey == null || IsKeyExpiredOrRevoked(intermediateKey)) { @@ -302,7 +315,7 @@ private async Task GetLatestOrCreateIntermediateKey() private async Task WithIntermediateKeyForRead( IKeyMeta intermediateKeyMeta, Func functionWithIntermediateKey) { - CryptoKey intermediateKey = _intermediateKeyCache.Get(intermediateKeyMeta.Created); + var intermediateKey = _intermediateKeyCache.Get(intermediateKeyMeta.Created); if (intermediateKey == null) { @@ -461,8 +474,7 @@ private async Task GetLatestOrCreateSystemKey() var newSystemKeyRecord = new KeyRecord( systemKey.GetCreated(), Convert.ToBase64String(await _keyManagementService.EncryptKeyAsync(systemKey)), - false, - null); // No parent key for system keys + false); // No parent key for system keys if (await _metastore.StoreAsync(_partition.SystemKeyId, newSystemKeyRecord.Created, newSystemKeyRecord)) { @@ -583,7 +595,7 @@ private static void DisposeKey(CryptoKey cryptoKey, Exception rootException) { if (rootException != null) { - AggregateException aggregateException = new AggregateException(ex, rootException); + var aggregateException = new AggregateException(ex, rootException); throw new AppEncryptionException( $"Failed to dispose/wipe key, error: {ex.Message}", aggregateException); } @@ -609,7 +621,7 @@ private static DataRowRecord DeserializeDataRowRecord(byte[] dataRowRecordBytes) DataRowRecord result; try { - result = JsonSerializer.Deserialize(dataRowRecordBytes, JsonOptions); + result = JsonSerializer.Deserialize(dataRowRecordBytes, JsonReadOptions); } catch (JsonException ex) { diff --git a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastore.cs similarity index 75% rename from csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs rename to csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastore.cs index deebcd7a4..fe3665bfb 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastoreImpl.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastore.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Data; using System.Linq; using System.Threading.Tasks; @@ -10,12 +9,12 @@ namespace GoDaddy.Asherah.AppEncryption.Metastore /// Provides a volatile implementation of for key records using a /// . NOTE: This should NEVER be used in a production environment. /// - public class InMemoryKeyMetastoreImpl : IKeyMetastore, IDisposable + public class InMemoryKeyMetastore : IKeyMetastore, IDisposable { - private readonly DataTable dataTable; + private readonly DataTable _dataTable; /// - /// Initializes a new instance of the class, with 3 columns. + /// Initializes a new instance of the class, with 3 columns. /// /// keyId | created | keyRecord /// ----- | ------- | --------- @@ -24,21 +23,21 @@ public class InMemoryKeyMetastoreImpl : IKeyMetastore, IDisposable /// /// Uses 'keyId' and 'created' as the primary key. /// - public InMemoryKeyMetastoreImpl() + public InMemoryKeyMetastore() { - dataTable = new DataTable(); - dataTable.Columns.Add("keyId", typeof(string)); - dataTable.Columns.Add("created", typeof(DateTimeOffset)); - dataTable.Columns.Add("keyRecord", typeof(KeyRecord)); - dataTable.PrimaryKey = new[] { dataTable.Columns["keyId"], dataTable.Columns["created"] }; + _dataTable = new DataTable(); + _dataTable.Columns.Add("keyId", typeof(string)); + _dataTable.Columns.Add("created", typeof(DateTimeOffset)); + _dataTable.Columns.Add("keyRecord", typeof(KeyRecord)); + _dataTable.PrimaryKey = new[] { _dataTable.Columns["keyId"], _dataTable.Columns["created"] }; } /// public Task<(bool found, IKeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) { - lock (dataTable) + lock (_dataTable) { - List dataRows = dataTable.Rows.Cast() + var dataRows = _dataTable.Rows.Cast() .Where(row => row["keyId"].Equals(keyId) && row["created"].Equals(created)) .ToList(); @@ -55,9 +54,9 @@ public InMemoryKeyMetastoreImpl() /// public Task<(bool found, IKeyRecord keyRecord)> TryLoadLatestAsync(string keyId) { - lock (dataTable) + lock (_dataTable) { - List dataRows = dataTable.Rows.Cast() + var dataRows = _dataTable.Rows.Cast() .Where(row => row["keyId"].Equals(keyId)) .OrderBy(row => row["created"]) .ToList(); @@ -76,9 +75,9 @@ public InMemoryKeyMetastoreImpl() /// public Task StoreAsync(string keyId, DateTimeOffset created, IKeyRecord keyRecord) { - lock (dataTable) + lock (_dataTable) { - List dataRows = dataTable.Rows.Cast() + var dataRows = _dataTable.Rows.Cast() .Where(row => row["keyId"].Equals(keyId) && row["created"].Equals(created)) .ToList(); @@ -87,7 +86,7 @@ public Task StoreAsync(string keyId, DateTimeOffset created, IKeyRecord ke return Task.FromResult(false); } - dataTable.Rows.Add(keyId, created, keyRecord); + _dataTable.Rows.Add(keyId, created, keyRecord); return Task.FromResult(true); } } @@ -112,9 +111,14 @@ public void Dispose() /// True if called from Dispose, false if called from finalizer. protected virtual void Dispose(bool disposing) { - if (disposing) + if (!disposing) { - dataTable?.Dispose(); + return; + } + + lock (_dataTable) + { + _dataTable?.Dispose(); } } } diff --git a/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs b/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs index e050a9a68..a48eace51 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/KeyMeta.cs @@ -1,6 +1,4 @@ using System; -using System.Text.Json.Serialization; -using GoDaddy.Asherah.AppEncryption.Serialization; namespace GoDaddy.Asherah.AppEncryption.Metastore { @@ -17,7 +15,6 @@ public class KeyMeta : IKeyMeta /// /// Gets or sets the creation time of the key. /// - [JsonConverter(typeof(UnixTimestampDateTimeOffsetConverter))] public DateTimeOffset Created { get; set; } /// diff --git a/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs b/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs index 14c9af964..c58df5769 100644 --- a/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs +++ b/csharp/AppEncryption/AppEncryption/Metastore/KeyRecord.cs @@ -1,6 +1,4 @@ using System; -using System.Text.Json.Serialization; -using GoDaddy.Asherah.AppEncryption.Serialization; namespace GoDaddy.Asherah.AppEncryption.Metastore { @@ -30,7 +28,6 @@ public KeyRecord(DateTimeOffset created, string key, bool? revoked, IKeyMeta par /// /// Gets the creation time of the encrypted key. /// - [JsonConverter(typeof(UnixTimestampDateTimeOffsetConverter))] public DateTimeOffset Created { get; } /// diff --git a/csharp/AppEncryption/AppEncryption/Serialization/InterfaceConverter.cs b/csharp/AppEncryption/AppEncryption/Serialization/InterfaceConverter.cs index b262baa6d..469e1975a 100644 --- a/csharp/AppEncryption/AppEncryption/Serialization/InterfaceConverter.cs +++ b/csharp/AppEncryption/AppEncryption/Serialization/InterfaceConverter.cs @@ -20,11 +20,9 @@ public override TInterface Read(ref Utf8JsonReader reader, Type typeToConvert, J return JsonSerializer.Deserialize(ref reader, options); } - /// public override void Write(Utf8JsonWriter writer, TInterface value, JsonSerializerOptions options) { - // Write implementation can be added if needed for serialization - throw new NotImplementedException("Write operation not implemented"); + throw new NotImplementedException(); } } } From ce2441cdace04fbc98070fb3ffa25a1581ba41e9 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Wed, 17 Sep 2025 13:39:03 -0700 Subject: [PATCH 17/31] Added async tests for old kms implementation --- .../Kms/AwsKeyManagementServiceImplTest.cs | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs index ce3afe131..e68bf2b6e 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs @@ -130,6 +130,45 @@ public void TestDecryptKeySuccessful() Assert.Equal(cryptoKeyMock.Object, actualCryptoKey); } + [Fact] + public async Task TestDecryptKeyAsyncSuccessful() + { + byte[] encryptedKey = { 0, 1 }; + byte[] kmsKeyEncryptionKey = { 2, 3 }; + + JObject kmsKeyEnvelopeTest = JObject.FromObject(new Dictionary + { + { EncryptedKey, Convert.ToBase64String(encryptedKey) }, + { + KmsKeksKey, new List> + { + new Dictionary + { + { RegionKey, UsWest1 }, + { ArnKey, ArnUsWest1 }, + { EncryptedKek, Convert.ToBase64String(kmsKeyEncryptionKey) }, + }, + } + }, + }); + + DateTimeOffset now = DateTimeOffset.UtcNow; + bool revoked = false; + awsKeyManagementServiceImplSpy + .Setup(x => x.DecryptKmsEncryptedKey( + amazonKeyManagementServiceClientMock.Object, + encryptedKey, + now, + kmsKeyEncryptionKey, + revoked)) + .Returns(cryptoKeyMock.Object); + + CryptoKey actualCryptoKey = await awsKeyManagementServiceImplSpy.Object.DecryptKeyAsync( + new Asherah.AppEncryption.Util.Json(kmsKeyEnvelopeTest).ToUtf8(), now, revoked); + Assert.Equal(cryptoKeyMock.Object, actualCryptoKey); + } + + [Fact] public void TestDecryptKeyWithMissingRegionInPayloadShouldSkipAndSucceed() { @@ -521,6 +560,83 @@ public void TestEncryptKeySuccessful() } } + [Fact] + public async Task TestEncryptKeyAsyncSuccessful() + { + byte[] encryptedKey = { 3, 4 }; + byte[] dataKeyPlainText = { 1, 2 }; + byte[] dataKeyCipherText = { 5, 6 }; + byte[] encryptKeyCipherText = { 7, 8 }; + + JObject encryptKeyAndBuildResultJson = JObject.FromObject(new Dictionary + { + { RegionKey, UsEast1 }, + { ArnKey, ArnUsEast1 }, + { EncryptedKek, Convert.ToBase64String(encryptKeyCipherText) }, + }); + + JObject kmsKeyEnvelope = JObject.FromObject(new Dictionary + { + { EncryptedKey, Convert.ToBase64String(encryptedKey) }, + { + KmsKeksKey, new List + { + new Dictionary + { + { RegionKey, UsWest1 }, + { ArnKey, ArnUsWest1 }, + { EncryptedKek, Convert.ToBase64String(dataKeyCipherText) }, + }, + encryptKeyAndBuildResultJson, + } + }, + }); + GenerateDataKeyResponse generateDataKeyResult = new GenerateDataKeyResponse + { + Plaintext = new MemoryStream(dataKeyPlainText, 0, dataKeyPlainText.Length, true, true), + CiphertextBlob = new MemoryStream(dataKeyCipherText, 0, dataKeyCipherText.Length, true, true), + }; + + Mock generatedDataKeyCryptoKey = new Mock(); + string keyId = ArnUsWest1; + + string outKeyId = keyId; + awsKeyManagementServiceImplSpy + .Setup(x => x.GenerateDataKey( + It.IsAny(), + out outKeyId)) + .Returns(generateDataKeyResult); + cryptoMock.Setup(x => x.GenerateKeyFromBytes(generateDataKeyResult.Plaintext.ToArray())) + .Returns(generatedDataKeyCryptoKey.Object); + cryptoMock.Setup(x => x.EncryptKey(cryptoKeyMock.Object, generatedDataKeyCryptoKey.Object)) + .Returns(encryptedKey); + awsKeyManagementServiceImplSpy.Setup(x => + x.EncryptKeyAndBuildResult( + It.IsAny(), + UsEast1, + ArnUsEast1, + dataKeyPlainText)) + .Returns(Option.Some(encryptKeyAndBuildResultJson)); + + byte[] encryptedResult = await awsKeyManagementServiceImplSpy.Object.EncryptKeyAsync(cryptoKeyMock.Object); + JObject kmsKeyEnvelopeResult = new Asherah.AppEncryption.Util.Json(encryptedResult).ToJObject(); + + Assert.Equal(new byte[] { 0, 0 }, dataKeyPlainText); + + // This is a workaround for https://github.com/JamesNK/Newtonsoft.Json/issues/1437 + // If DeepEquals fails due to mismatching array order, compare the elements individually + if (!JToken.DeepEquals(kmsKeyEnvelope, kmsKeyEnvelopeResult)) + { + JArray kmsKeyEnvelopeKmsKeks = JArray.FromObject(kmsKeyEnvelope[KmsKeksKey] + .OrderBy(k => k[RegionKey])); + JArray kmsKeyEnvelopeResultKmsKeks = JArray.FromObject(kmsKeyEnvelopeResult[KmsKeksKey] + .OrderBy(k => k[RegionKey])); + + Assert.True(JToken.DeepEquals(kmsKeyEnvelope[EncryptedKey], kmsKeyEnvelopeResult[EncryptedKey])); + Assert.True(JToken.DeepEquals(kmsKeyEnvelopeKmsKeks, kmsKeyEnvelopeResultKmsKeks)); + } + } + [Fact] public void TestEncryptKeyShouldThrowExceptionAndWipeBytes() { From 81029ca0e5b3cc22b7b22a6dde3c393af027abbe Mon Sep 17 00:00:00 2001 From: chief-micco Date: Wed, 17 Sep 2025 13:52:32 -0700 Subject: [PATCH 18/31] added async envelope tests --- .../EnvelopeEncryptionBytesImplTest.cs | 28 ++++++++ .../EnvelopeEncryptionJsonImplTest.cs | 71 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionBytesImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionBytesImplTest.cs index f2377e2de..65086c2ad 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionBytesImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionBytesImplTest.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Envelope; using Microsoft.Extensions.Logging; using Moq; @@ -36,6 +37,20 @@ public void TestDecryptDataRowRecord() Assert.Equal(expectedBytes, actualBytes); } + [Fact] + public async Task TestDecryptDataRowRecordAsync() + { + byte[] expectedBytes = { 0, 1 }; + + envelopeEncryptionJsonImplMock.Setup(x => x.DecryptDataRowRecord(It.IsAny())) + .Returns(expectedBytes); + + ImmutableDictionary immutableDictionary = new Dictionary { { "key", "value" } }.ToImmutableDictionary(); + byte[] dataRowRecordBytes = new Asherah.AppEncryption.Util.Json(JObject.FromObject(immutableDictionary)).ToUtf8(); + byte[] actualBytes = await envelopeEncryptionBytesImpl.DecryptDataRowRecordAsync(dataRowRecordBytes); + Assert.Equal(expectedBytes, actualBytes); + } + [Fact] public void TestEncryptPayload() { @@ -49,6 +64,19 @@ public void TestEncryptPayload() Assert.Equal(expectedBytes, actualResult); } + [Fact] + public async Task TestEncryptPayloadAsync() + { + ImmutableDictionary immutableDictionary = new Dictionary { { "key", "value" } }.ToImmutableDictionary(); + JObject dataRowRecord = JObject.FromObject(immutableDictionary); + byte[] expectedBytes = { 123, 34, 107, 101, 121, 34, 58, 34, 118, 97, 108, 117, 101, 34, 125 }; + + envelopeEncryptionJsonImplMock.Setup(x => x.EncryptPayload(It.IsAny())).Returns(dataRowRecord); + + byte[] actualResult = await envelopeEncryptionBytesImpl.EncryptPayloadAsync(new byte[] { 0, 1 }); + Assert.Equal(expectedBytes, actualResult); + } + [Fact] public void TestDisposeSuccess() { diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionJsonImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionJsonImplTest.cs index 4857e1f96..5fe658de8 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionJsonImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionJsonImplTest.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Text; +using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Envelope; using GoDaddy.Asherah.AppEncryption.Exceptions; using GoDaddy.Asherah.AppEncryption.Kms; @@ -100,6 +101,33 @@ private void TestDecryptDataRowRecordWithParentKeyMetaShouldSucceed() aeadEnvelopeCryptoMock.Verify(x => x.EnvelopeDecrypt(encryptedData, dataRowKey.EncryptedKey, dataRowKey.Created, intermediateCryptoKeyMock.Object)); } + [Fact] + private async Task TestDecryptDataRowRecordAsyncWithParentKeyMetaShouldSucceed() + { + KeyMeta intermediateKeyMeta = new KeyMeta(partition.IntermediateKeyId, ikDateTime); + EnvelopeKeyRecord dataRowKey = new EnvelopeKeyRecord(drkDateTime, intermediateKeyMeta, new byte[] { 0, 1, 2, 3 }); + byte[] encryptedData = { 4, 5, 6, 7 }; + + JObject dataRowRecord = JObject.FromObject(new Dictionary + { + { "Key", dataRowKey.ToJson() }, + { "Data", Convert.ToBase64String(encryptedData) }, + }); + + envelopeEncryptionJsonImplSpy.Setup(x => x.WithIntermediateKeyForRead(intermediateKeyMeta, It.IsAny>())) + .Returns>((keyMeta, functionWithIntermediateKey) => functionWithIntermediateKey(intermediateCryptoKeyMock.Object)); + + byte[] expectedDecryptedPayload = { 11, 12, 13, 14 }; + aeadEnvelopeCryptoMock + .Setup(x => x.EnvelopeDecrypt( + encryptedData, dataRowKey.EncryptedKey, dataRowKey.Created, intermediateCryptoKeyMock.Object)) + .Returns(expectedDecryptedPayload); + + byte[] actualDecryptedPayload = await envelopeEncryptionJsonImplSpy.Object.DecryptDataRowRecordAsync(dataRowRecord); + Assert.Equal(expectedDecryptedPayload, actualDecryptedPayload); + aeadEnvelopeCryptoMock.Verify(x => x.EnvelopeDecrypt(encryptedData, dataRowKey.EncryptedKey, dataRowKey.Created, intermediateCryptoKeyMock.Object)); + } + [Fact] private void TestDecryptDataRowRecordWithInvalidParentKeyMetaShouldFail() { @@ -175,6 +203,49 @@ private void TestEncryptPayload() actualDataRowRecord.GetValue("Key").ToObject().GetValue("ParentKeyMeta").ToObject())); } + [Fact] + private async Task TestEncryptPayloadAsync() + { + intermediateCryptoKeyMock.Setup(x => x.GetCreated()).Returns(ikDateTime); + + envelopeEncryptionJsonImplSpy + .Setup(x => x.WithIntermediateKeyForWrite(It.IsAny>>())) + .Returns>>(functionWithIntermediateKey => + functionWithIntermediateKey(intermediateCryptoKeyMock.Object)); + + byte[] decryptedPayload = Encoding.Unicode.GetBytes("somepayload"); + KeyMeta intermediateKeyMeta = new KeyMeta(partition.IntermediateKeyId, ikDateTime); + byte[] encryptedPayload = { 0, 1, 2, 3 }; + byte[] encryptedKey = { 4, 5, 6, 7 }; + EnvelopeEncryptResult envelopeEncryptResult = new EnvelopeEncryptResult + { + CipherText = encryptedPayload, + EncryptedKey = encryptedKey, + UserState = intermediateKeyMeta, + }; + aeadEnvelopeCryptoMock + .Setup(x => x.EnvelopeEncrypt(decryptedPayload, intermediateCryptoKeyMock.Object, intermediateKeyMeta)) + .Returns(envelopeEncryptResult); + + EnvelopeKeyRecord expectedDataRowKey = new EnvelopeKeyRecord(drkDateTime, intermediateKeyMeta, encryptedKey); + JObject expectedDataRowRecord = JObject.FromObject(new Dictionary + { + { "Key", expectedDataRowKey.ToJson() }, + { "Data", Convert.ToBase64String(encryptedPayload) }, + }); + + JObject actualDataRowRecord = await envelopeEncryptionJsonImplSpy.Object.EncryptPayloadAsync(decryptedPayload); + + // Asserting individual fields as work-around to hard-coding DateTimeOffset.UtcNow usage + Assert.Equal(expectedDataRowRecord.GetValue("Data").ToObject(), actualDataRowRecord.GetValue("Data").ToObject()); + Assert.Equal( + expectedDataRowRecord.GetValue("Key").ToObject().GetValue("Key").ToString(), + actualDataRowRecord.GetValue("Key").ToObject().GetValue("Key").ToString()); + Assert.True(JToken.DeepEquals( + expectedDataRowRecord.GetValue("Key").ToObject().GetValue("ParentKeyMeta").ToObject(), + actualDataRowRecord.GetValue("Key").ToObject().GetValue("ParentKeyMeta").ToObject())); + } + [Fact] private void TestDisposeSuccess() { From 771a3016216859df7c1e3ce5a1b33bf5949f9824 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Wed, 1 Oct 2025 07:11:15 -0700 Subject: [PATCH 19/31] changed Extensions library to PlugIns library --- .../AppEncryption.PlugIns.Aws.csproj} | 6 +++--- .../Kms/IKeyManagementClientFactory.cs | 2 +- .../Kms/KeyManagementClientFactory.cs | 2 +- .../Kms/KeyManagementService.cs | 2 +- .../Kms/KeyManagementServiceOptions.cs | 2 +- .../Kms/KmsArnClient.cs | 2 +- .../Kms/RegionKeyArn.cs | 2 +- .../Metastore/DynamoDbMetastore.cs | 2 +- .../Metastore/DynamoDbMetastoreOptions.cs | 2 +- .../AppEncryption.Tests/AppEncryption.Tests.csproj | 2 +- .../AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs | 4 ++-- .../{Extensions => PlugIns}/Aws/Kms/AwsKeyManagementStub.cs | 2 +- .../Aws/Kms/KeyManagementClientFactoryStub.cs | 4 ++-- .../Aws/Kms/KeyManagementServiceTests.cs | 4 ++-- .../Aws/Metastore/DynamoDbMetastoreTests.cs | 4 ++-- csharp/AppEncryption/AppEncryption.slnx | 2 +- tartufo.toml | 1 + 17 files changed, 23 insertions(+), 22 deletions(-) rename csharp/AppEncryption/{AppEncryption.Extensions.Aws/AppEncryption.Extensions.Aws.csproj => AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj} (89%) rename csharp/AppEncryption/{AppEncryption.Extensions.Aws => AppEncryption.PlugIns.Aws}/Kms/IKeyManagementClientFactory.cs (89%) rename csharp/AppEncryption/{AppEncryption.Extensions.Aws => AppEncryption.PlugIns.Aws}/Kms/KeyManagementClientFactory.cs (96%) rename csharp/AppEncryption/{AppEncryption.Extensions.Aws => AppEncryption.PlugIns.Aws}/Kms/KeyManagementService.cs (99%) rename csharp/AppEncryption/{AppEncryption.Extensions.Aws => AppEncryption.PlugIns.Aws}/Kms/KeyManagementServiceOptions.cs (89%) rename csharp/AppEncryption/{AppEncryption.Extensions.Aws => AppEncryption.PlugIns.Aws}/Kms/KmsArnClient.cs (94%) rename csharp/AppEncryption/{AppEncryption.Extensions.Aws => AppEncryption.PlugIns.Aws}/Kms/RegionKeyArn.cs (90%) rename csharp/AppEncryption/{AppEncryption.Extensions.Aws => AppEncryption.PlugIns.Aws}/Metastore/DynamoDbMetastore.cs (99%) rename csharp/AppEncryption/{AppEncryption.Extensions.Aws => AppEncryption.PlugIns.Aws}/Metastore/DynamoDbMetastoreOptions.cs (82%) rename csharp/AppEncryption/AppEncryption.Tests/AppEncryption/{Extensions => PlugIns}/Aws/Kms/AwsKeyManagementStub.cs (99%) rename csharp/AppEncryption/AppEncryption.Tests/AppEncryption/{Extensions => PlugIns}/Aws/Kms/KeyManagementClientFactoryStub.cs (92%) rename csharp/AppEncryption/AppEncryption.Tests/AppEncryption/{Extensions => PlugIns}/Aws/Kms/KeyManagementServiceTests.cs (98%) rename csharp/AppEncryption/AppEncryption.Tests/AppEncryption/{Extensions => PlugIns}/Aws/Metastore/DynamoDbMetastoreTests.cs (98%) diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/AppEncryption.Extensions.Aws.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj similarity index 89% rename from csharp/AppEncryption/AppEncryption.Extensions.Aws/AppEncryption.Extensions.Aws.csproj rename to csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj index a8df3ea6a..40f462c89 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/AppEncryption.Extensions.Aws.csproj +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj @@ -1,14 +1,14 @@ - GoDaddy.Asherah.AppEncryption.Extensions.Aws - AppEncryption.Extensions.Aws + 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.Extensions.Aws + GoDaddy.Asherah.AppEncryption.PlugIns.Aws true true Recommended diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/IKeyManagementClientFactory.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementClientFactory.cs similarity index 89% rename from csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/IKeyManagementClientFactory.cs rename to csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementClientFactory.cs index c8c6be0c5..eb15855bb 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/IKeyManagementClientFactory.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/IKeyManagementClientFactory.cs @@ -1,6 +1,6 @@ using Amazon.KeyManagementService; -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms { /// /// Factory interface for creating AWS KMS clients for specific regions. diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementClientFactory.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementClientFactory.cs similarity index 96% rename from csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementClientFactory.cs rename to csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementClientFactory.cs index a03e57a21..e48c10b42 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementClientFactory.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementClientFactory.cs @@ -2,7 +2,7 @@ using Amazon.KeyManagementService; using Amazon.Runtime; -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms { /// /// Simple implementation of that creates KMS clients diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementService.cs similarity index 99% rename from csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs rename to csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementService.cs index 650a3423c..cf77afbfc 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementService.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementService.cs @@ -14,7 +14,7 @@ using GoDaddy.Asherah.Crypto.Keys; using Microsoft.Extensions.Logging; -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms { /// /// AWS-specific implementation of . diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementServiceOptions.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptions.cs similarity index 89% rename from csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementServiceOptions.cs rename to csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptions.cs index 0b1f8f3a2..eb1b505ab 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KeyManagementServiceOptions.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KeyManagementServiceOptions.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Text.Json.Serialization; -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms { /// /// Options for configuring the AWS Key Management Service. diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KmsArnClient.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KmsArnClient.cs similarity index 94% rename from csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KmsArnClient.cs rename to csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KmsArnClient.cs index 4ae891c08..1ac49c98d 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/KmsArnClient.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/KmsArnClient.cs @@ -1,6 +1,6 @@ using Amazon.KeyManagementService; -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms { /// /// Internal class that holds a KMS ARN and its corresponding client. diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/RegionKeyArn.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/RegionKeyArn.cs similarity index 90% rename from csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/RegionKeyArn.cs rename to csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/RegionKeyArn.cs index 64eb3beec..1c6a77368 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Kms/RegionKeyArn.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Kms/RegionKeyArn.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms { /// /// Represents a region and its corresponding KMS key ARN. diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastore.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastore.cs similarity index 99% rename from csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastore.cs rename to csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastore.cs index 6d4b18abe..291fba620 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastore.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastore.cs @@ -6,7 +6,7 @@ using Amazon.DynamoDBv2.Model; using GoDaddy.Asherah.AppEncryption.Metastore; -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore { /// /// Provides an AWS DynamoDB based implementation of to store and retrieve system keys diff --git a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreOptions.cs similarity index 82% rename from csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs rename to csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreOptions.cs index 00e92deda..7ad6c8ee5 100644 --- a/csharp/AppEncryption/AppEncryption.Extensions.Aws/Metastore/DynamoDbMetastoreOptions.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreOptions.cs @@ -1,4 +1,4 @@ -namespace GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore { /// /// Configuration options for DynamoDbMetastore. diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index 89409a82c..c20f33f9e 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -34,6 +34,6 @@ - + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs index e68bf2b6e..d8764f6ac 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Kms/AwsKeyManagementServiceImplTest.cs @@ -10,9 +10,9 @@ using Amazon.KeyManagementService.Model; using Amazon.Runtime; using GoDaddy.Asherah.AppEncryption.Exceptions; -using GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms; +using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms; using GoDaddy.Asherah.AppEncryption.Kms; -using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.Aws.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; diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/AwsKeyManagementStub.cs similarity index 99% rename from csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs rename to csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/AwsKeyManagementStub.cs index 800421f29..34acd2f79 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/AwsKeyManagementStub.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/AwsKeyManagementStub.cs @@ -7,7 +7,7 @@ using Amazon.KeyManagementService.Model; using Amazon.Runtime; -namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.Aws.Kms +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms { /// /// Stub implementation of IAmazonKeyManagementService for testing purposes. diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryStub.cs similarity index 92% rename from csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs rename to csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryStub.cs index a5e8c4582..5c8dc0177 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementClientFactoryStub.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementClientFactoryStub.cs @@ -4,10 +4,10 @@ using System.Linq; using Amazon.KeyManagementService; using Amazon.Runtime; -using GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms; +using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Kms; using GoDaddy.Asherah.AppEncryption.Kms; -namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.Aws.Kms +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms { /// /// Stub implementation of IKeyManagementClientFactory for testing purposes. diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs similarity index 98% rename from csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs rename to csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs index dad6e06e9..9854c6122 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Kms/KeyManagementServiceTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Kms/KeyManagementServiceTests.cs @@ -2,13 +2,13 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; -using GoDaddy.Asherah.AppEncryption.Extensions.Aws.Kms; +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.Extensions.Aws.Kms +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Kms { [ExcludeFromCodeCoverage] public class KeyManagementServiceTests : IDisposable diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreTests.cs similarity index 98% rename from csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs rename to csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreTests.cs index 9fb2fd0b7..d3700658b 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Extensions/Aws/Metastore/DynamoDbMetastoreTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreTests.cs @@ -5,13 +5,13 @@ using System.Threading.Tasks; using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.Model; -using GoDaddy.Asherah.AppEncryption.Extensions.Aws.Metastore; +using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore; using GoDaddy.Asherah.AppEncryption.Metastore; using GoDaddy.Asherah.AppEncryption.Tests.Fixtures; using Moq; using Xunit; -namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Extensions.Aws.Metastore; +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Metastore; [ExcludeFromCodeCoverage] public class DynamoDbMetastoreTests : IClassFixture, IDisposable diff --git a/csharp/AppEncryption/AppEncryption.slnx b/csharp/AppEncryption/AppEncryption.slnx index 7c01030df..7d2054458 100644 --- a/csharp/AppEncryption/AppEncryption.slnx +++ b/csharp/AppEncryption/AppEncryption.slnx @@ -2,6 +2,6 @@ - + diff --git a/tartufo.toml b/tartufo.toml index a2d3aa8de..b389d4652 100644 --- a/tartufo.toml +++ b/tartufo.toml @@ -54,6 +54,7 @@ exclude-signatures = [ { signature = "a4ea5817e072966bb7bfd712b89687465641b1ba58fba56eb35ef62755c0a5bd", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"}, { signature = "350ebba07491fe87c1abe10b84e1991237ddd8ee69f46304a921f7a6784d617e", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"}, { signature = "51e487e817c8c7333b7537a685e5c8e4f4afd84f7f41ad9b82117a95f4e54e1a", 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 04491b9f9478588f6ec1f87e666f3820e8315d90 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 15 Nov 2025 05:53:54 -0700 Subject: [PATCH 20/31] updated nuget references and merged main --- .../AppEncryption.IntegrationTests.csproj | 2 +- .../AppEncryption.PlugIns.Aws.csproj | 6 +++--- .../AppEncryption.Tests/AppEncryption.Tests.csproj | 2 +- csharp/AppEncryption/AppEncryption/AppEncryption.csproj | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) 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.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj index 40f462c89..2d7911ce8 100644 --- a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj @@ -23,9 +23,9 @@ - - - + + + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index 99d3e3ac7..e5ca63db9 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -10,7 +10,7 @@ latest - + all 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 @@ - - + + From 9e153c87fbae38e666135a7537966dc85885e015 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 10 Jan 2026 07:13:58 -0700 Subject: [PATCH 21/31] Updated nuget references --- .../AppEncryption.IntegrationTests.csproj | 4 ++-- .../AppEncryption.PlugIns.Aws.csproj | 4 ++-- .../AppEncryption.Tests/AppEncryption.Tests.csproj | 10 +++++----- .../AppEncryption/AppEncryption/AppEncryption.csproj | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index 6881d71c4..7a46703c6 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -10,7 +10,7 @@ $(NoWarn);CS0618;CA1816;CA1873 - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -25,7 +25,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj index 217996d41..be34fd1d7 100644 --- a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index e452a8565..5806ca9c8 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -10,8 +10,8 @@ $(NoWarn);CS0618;CA1816 - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -20,14 +20,14 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj index f44209116..f270d158a 100644 --- a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj +++ b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj @@ -24,8 +24,8 @@ - - + + From 44fdb252cac15a74e7eba127bbeeeb8dac7c1d88 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sun, 11 Jan 2026 07:19:57 -0700 Subject: [PATCH 22/31] Add Builder pattern and KeySuffix option to DynamoDbMetastore - Add IDynamoDbMetastoreBuilder interface and DynamoDbMetastoreBuilder implementation - Add KeySuffix option to DynamoDbMetastoreOptions for Global Tables support - Add constructor validation for required parameters - Add DynamoDbMetastoreBuilderTests with comprehensive test coverage - Update DynamoDbMetastoreTests with KeySuffix tests --- .../Metastore/DynamoDbMetastore.cs | 22 +- .../Metastore/DynamoDbMetastoreBuilder.cs | 33 +++ .../Metastore/DynamoDbMetastoreOptions.cs | 8 + .../Metastore/IDynamoDbMetastoreBuilder.cs | 31 +++ .../DynamoDbMetastoreBuilderTests.cs | 199 ++++++++++++++++++ .../Aws/Metastore/DynamoDbMetastoreTests.cs | 41 +++- 6 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreBuilder.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/IDynamoDbMetastoreBuilder.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreBuilderTests.cs diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastore.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastore.cs index 291fba620..d4d95bc4e 100644 --- a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastore.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastore.cs @@ -23,10 +23,17 @@ public class DynamoDbMetastore : IKeyMetastore /// /// The AWS DynamoDB client to use for operations. /// Configuration options for the metastore. + /// Thrown when dynamoDbClient or options is null. + /// Thrown when KeyRecordTableName is null or empty. public DynamoDbMetastore(IAmazonDynamoDB dynamoDbClient, DynamoDbMetastoreOptions options) { - _dynamoDbClient = dynamoDbClient; - _options = options; + _dynamoDbClient = dynamoDbClient ?? throw new ArgumentNullException(nameof(dynamoDbClient)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + + if (string.IsNullOrEmpty(_options.KeyRecordTableName)) + { + throw new ArgumentException("KeyRecordTableName must not be null or empty", nameof(options)); + } } private const string PartitionKey = "Id"; @@ -151,9 +158,20 @@ public async Task StoreAsync(string keyId, DateTimeOffset created, IKeyRec /// public string GetKeySuffix() { + if (_options.KeySuffix != null) + { + return _options.KeySuffix; + } + return _dynamoDbClient.Config.RegionEndpoint?.SystemName; } + /// + /// Creates a new instance for constructing a . + /// + /// A new instance. + public static IDynamoDbMetastoreBuilder NewBuilder() => new DynamoDbMetastoreBuilder(); + private static KeyRecord ConvertAttributeValueToKeyRecord(AttributeValue keyRecordAttribute) { if (keyRecordAttribute.M == null) diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreBuilder.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreBuilder.cs new file mode 100644 index 000000000..6a05c6f57 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreBuilder.cs @@ -0,0 +1,33 @@ +using System; +using Amazon.DynamoDBv2; + +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore +{ + internal sealed class DynamoDbMetastoreBuilder : IDynamoDbMetastoreBuilder + { + private IAmazonDynamoDB _dynamoDbClient; + private DynamoDbMetastoreOptions _options; + + public IDynamoDbMetastoreBuilder WithDynamoDbClient(IAmazonDynamoDB dynamoDbClient) + { + _dynamoDbClient = dynamoDbClient; + return this; + } + + public IDynamoDbMetastoreBuilder WithOptions(DynamoDbMetastoreOptions options) + { + _options = options; + return this; + } + + public DynamoDbMetastore Build() + { + if (_dynamoDbClient == null) + { + throw new InvalidOperationException("DynamoDB client must be set using WithDynamoDbClient()"); + } + + return new DynamoDbMetastore(_dynamoDbClient, _options ?? new DynamoDbMetastoreOptions()); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreOptions.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreOptions.cs index 7ad6c8ee5..41c251d76 100644 --- a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreOptions.cs +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/DynamoDbMetastoreOptions.cs @@ -9,5 +9,13 @@ public class DynamoDbMetastoreOptions /// The table name for the KeyRecord storage /// public string KeyRecordTableName { get; set; } = "KeyRecord"; + + /// + /// The key suffix to use for key IDs to support DynamoDB Global Tables. When null (default), + /// the region from the DynamoDB client is used as the suffix. Set to an empty string to disable + /// key suffix for backwards compatibility with existing data that was stored without suffixes. + /// Can also be set to an arbitrary string for custom suffix values. + /// + public string KeySuffix { get; set; } }; } diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/IDynamoDbMetastoreBuilder.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/IDynamoDbMetastoreBuilder.cs new file mode 100644 index 000000000..77edace5c --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/Metastore/IDynamoDbMetastoreBuilder.cs @@ -0,0 +1,31 @@ +using Amazon.DynamoDBv2; + +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore +{ + /// + /// Builder interface for constructing a instance. + /// + public interface IDynamoDbMetastoreBuilder + { + /// + /// Sets the DynamoDB client to use for operations. + /// + /// The AWS DynamoDB client. + /// The current instance. + IDynamoDbMetastoreBuilder WithDynamoDbClient(IAmazonDynamoDB dynamoDbClient); + + /// + /// Sets the configuration options for the metastore. + /// + /// The configuration options. + /// The current instance. + IDynamoDbMetastoreBuilder WithOptions(DynamoDbMetastoreOptions options); + + /// + /// Builds the instance. + /// + /// A new instance. + /// Thrown when the DynamoDB client is not set. + DynamoDbMetastore Build(); + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreBuilderTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreBuilderTests.cs new file mode 100644 index 000000000..270a3c2a5 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreBuilderTests.cs @@ -0,0 +1,199 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Amazon.DynamoDBv2; +using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore; +using Moq; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Metastore; + +[ExcludeFromCodeCoverage] +public class DynamoDbMetastoreBuilderTests +{ + [Fact] + public void Build_WithoutDynamoDbClient_ThrowsInvalidOperationException() + { + var builder = DynamoDbMetastore.NewBuilder(); + + var ex = Assert.Throws(() => builder.Build()); + Assert.Equal("DynamoDB client must be set using WithDynamoDbClient()", ex.Message); + } + + [Fact] + public void Build_WithDynamoDbClient_Succeeds() + { + var mockClient = new Mock(); + var builder = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(mockClient.Object); + + var metastore = builder.Build(); + + Assert.NotNull(metastore); + } + + [Fact] + public void Build_WithDynamoDbClientAndOptions_Succeeds() + { + var mockClient = new Mock(); + var options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = "CustomTable", + KeySuffix = "us-west-2" + }; + + var builder = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(mockClient.Object) + .WithOptions(options); + + var metastore = builder.Build(); + + Assert.NotNull(metastore); + } + + [Fact] + public void Build_WithOptionsHavingEmptyTableName_ThrowsArgumentException() + { + var mockClient = new Mock(); + var options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = string.Empty + }; + + var builder = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(mockClient.Object) + .WithOptions(options); + + var ex = Assert.Throws(() => builder.Build()); + Assert.Contains("KeyRecordTableName", ex.Message); + } + + [Fact] + public void Build_WithOptionsHavingNullTableName_ThrowsArgumentException() + { + var mockClient = new Mock(); + var options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = null + }; + + var builder = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(mockClient.Object) + .WithOptions(options); + + var ex = Assert.Throws(() => builder.Build()); + Assert.Contains("KeyRecordTableName", ex.Message); + } + + [Fact] + public void Build_WithoutOptions_UsesDefaultOptionsAndReturnsClientRegion() + { + var mockConfig = new AmazonDynamoDBConfig + { + RegionEndpoint = Amazon.RegionEndpoint.APSouth2 + }; + var mockClient = new Mock(); + mockClient.Setup(x => x.Config).Returns(mockConfig); + + var builder = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(mockClient.Object); + + var metastore = builder.Build(); + + Assert.NotNull(metastore); + // Default KeySuffix is null, so GetKeySuffix returns the client's region from config + Assert.Equal("ap-south-2", metastore.GetKeySuffix()); + } + + [Fact] + public void Build_WithKeySuffixDisabled_ReturnsEmptyKeySuffix() + { + var mockClient = new Mock(); + var options = new DynamoDbMetastoreOptions + { + KeySuffix = string.Empty + }; + + var builder = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(mockClient.Object) + .WithOptions(options); + + var metastore = builder.Build(); + + Assert.NotNull(metastore); + Assert.Equal(string.Empty, metastore.GetKeySuffix()); + } + + [Fact] + public void Build_WithCustomKeySuffix_ReturnsCustomKeySuffix() + { + var mockClient = new Mock(); + var options = new DynamoDbMetastoreOptions + { + KeySuffix = "my-custom-suffix" + }; + + var builder = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(mockClient.Object) + .WithOptions(options); + + var metastore = builder.Build(); + + Assert.NotNull(metastore); + Assert.Equal("my-custom-suffix", metastore.GetKeySuffix()); + } + + [Fact] + public void Build_WithCustomTableNameAndDefaultKeySuffix_UsesClientRegion() + { + var mockConfig = new AmazonDynamoDBConfig + { + RegionEndpoint = Amazon.RegionEndpoint.AFSouth1 + }; + var mockClient = new Mock(); + mockClient.Setup(x => x.Config).Returns(mockConfig); + + var options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = "MyCustomTable" + // KeySuffix is null (default) - should use client's region + }; + + var builder = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(mockClient.Object) + .WithOptions(options); + + var metastore = builder.Build(); + + Assert.NotNull(metastore); + // KeySuffix is null, so GetKeySuffix should return the client's region + Assert.Equal("af-south-1", metastore.GetKeySuffix()); + } + + [Fact] + public void Build_WithCustomTableNameAndDefaultKeySuffix_SupportsNoRegion() + { + var mockConfig = new AmazonDynamoDBConfig + { + // No RegionEndpoint set - simulates using ServiceURL for local DynamoDB + ServiceURL = "http://localhost:8000" + }; + var mockClient = new Mock(); + mockClient.Setup(x => x.Config).Returns(mockConfig); + + var options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = "MyCustomTable" + // KeySuffix is null (default) - should use client's region + }; + + var builder = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(mockClient.Object) + .WithOptions(options); + + var metastore = builder.Build(); + + Assert.NotNull(metastore); + // KeySuffix is null and no RegionEndpoint, so GetKeySuffix should return null + Assert.Null(metastore.GetKeySuffix()); + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreTests.cs index d3700658b..502698e37 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreTests.cs @@ -190,15 +190,50 @@ await Assert.ThrowsAsync( } [Fact] - public void GetKeySuffixShouldReturnRegionEndpointName() + public void GetKeySuffixShouldReturnNullWhenUsingServiceUrl() { - // Act + // When using ServiceURL (like for local DynamoDB), RegionEndpoint is null var result = _dynamoDbMetastore.GetKeySuffix(); - // Assert Assert.Null(result); } + [Fact] + public void GetKeySuffixShouldReturnEmptyWhenSetToEmpty() + { + // Arrange + var options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = TestTableName, + KeySuffix = string.Empty + }; + var metastore = new DynamoDbMetastore(_amazonDynamoDbClient, options); + + // Act + var result = metastore.GetKeySuffix(); + + // Assert + Assert.Equal(string.Empty, result); + } + + [Fact] + public void GetKeySuffixShouldReturnCustomValueWhenSet() + { + // Arrange + var options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = TestTableName, + KeySuffix = "my-custom-suffix" + }; + var metastore = new DynamoDbMetastore(_amazonDynamoDbClient, options); + + // Act + var result = metastore.GetKeySuffix(); + + // Assert + Assert.Equal("my-custom-suffix", result); + } + [Theory] [InlineData(null, true)] // null Revoked, with ParentKeyMeta [InlineData(false, true)] // false Revoked, with ParentKeyMeta From 40ef0f11cd383cf74c230464f4a10e7c1cfac181 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sun, 11 Jan 2026 07:47:59 -0700 Subject: [PATCH 23/31] add placeholder for metastore compatibility tests --- .../Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs new file mode 100644 index 000000000..e96866cb6 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs @@ -0,0 +1,8 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Metastore; + +[ExcludeFromCodeCoverage] +public class DynamoDbMetastoreCompatibilityTests +{ +} From 7e1a4bdc07e41898e917d5417b2a90765ed493a7 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Mon, 12 Jan 2026 07:19:08 -0700 Subject: [PATCH 24/31] compatibility tests already exist --- .../Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs deleted file mode 100644 index e96866cb6..000000000 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/PlugIns/Aws/Metastore/DynamoDbMetastoreCompatibilityTests.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.PlugIns.Aws.Metastore; - -[ExcludeFromCodeCoverage] -public class DynamoDbMetastoreCompatibilityTests -{ -} From 88b8e4bd48c0cf3f554ae923b56d684f5cfd754e Mon Sep 17 00:00:00 2001 From: chief-micco Date: Mon, 12 Jan 2026 12:35:13 -0700 Subject: [PATCH 25/31] moved test implemenation to a plugins.testing --- .../AppEncryption.PlugIns.Testing.csproj | 29 + .../Metastore/InMemoryKeyMetastore.cs | 281 +++++ .../AppEncryption.Tests.csproj | 1 + .../Envelope/EnvelopeEncryptionTests.cs | 973 +++++++++++++++++- .../Metastore/InMemoryKeyMetastoreTest.cs | 1 + .../Dummy/ConfigurableCryptoPolicy.cs | 101 ++ csharp/AppEncryption/AppEncryption.slnx | 1 + .../Metastore/InMemoryKeyMetastore.cs | 125 --- 8 files changed, 1385 insertions(+), 127 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Testing/AppEncryption.PlugIns.Testing.csproj create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Testing/Metastore/InMemoryKeyMetastore.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/Dummy/ConfigurableCryptoPolicy.cs delete mode 100644 csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastore.cs diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Testing/AppEncryption.PlugIns.Testing.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Testing/AppEncryption.PlugIns.Testing.csproj new file mode 100644 index 000000000..2523c78d0 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Testing/AppEncryption.PlugIns.Testing.csproj @@ -0,0 +1,29 @@ + + + GoDaddy.Asherah.AppEncryption.PlugIns.Testing + AppEncryption.PlugIns.Testing + GoDaddy + GoDaddy + Testing extensions for Application level envelope encryption SDK for C# + net8.0;net9.0;net10.0;netstandard2.0 + + + GoDaddy.Asherah.AppEncryption.PlugIns.Testing + 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.Testing/Metastore/InMemoryKeyMetastore.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Testing/Metastore/InMemoryKeyMetastore.cs new file mode 100644 index 000000000..4e5d54a44 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Testing/Metastore/InMemoryKeyMetastore.cs @@ -0,0 +1,281 @@ +using System; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Metastore; + +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Metastore +{ + /// + /// Provides a volatile implementation of for key records using a + /// . NOTE: This should NEVER be used in a production environment. + /// + public class InMemoryKeyMetastore : IKeyMetastore, IDisposable + { + private readonly DataTable _dataTable; + private int _failNextStoreCount; + + /// + /// Initializes a new instance of the class, with 3 columns. + /// + /// keyId | created | keyRecord + /// ----- | ------- | --------- + /// | | + /// | | + /// + /// Uses 'keyId' and 'created' as the primary key. + /// + public InMemoryKeyMetastore() + { + _dataTable = new DataTable(); + _dataTable.Columns.Add("keyId", typeof(string)); + _dataTable.Columns.Add("created", typeof(DateTimeOffset)); + _dataTable.Columns.Add("keyRecord", typeof(KeyRecord)); + _dataTable.PrimaryKey = new[] { _dataTable.Columns["keyId"], _dataTable.Columns["created"] }; + } + + /// + public Task<(bool found, IKeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) + { + lock (_dataTable) + { + var dataRows = _dataTable.Rows.Cast() + .Where(row => row["keyId"].Equals(keyId) + && row["created"].Equals(created)) + .ToList(); + if (dataRows.Count == 0) + { + return Task.FromResult((false, (IKeyRecord)null)); + } + + var keyRecord = (IKeyRecord)dataRows.Single()["keyRecord"]; + return Task.FromResult((true, keyRecord)); + } + } + + /// + public Task<(bool found, IKeyRecord keyRecord)> TryLoadLatestAsync(string keyId) + { + lock (_dataTable) + { + var dataRows = _dataTable.Rows.Cast() + .Where(row => row["keyId"].Equals(keyId)) + .OrderBy(row => row["created"]) + .ToList(); + + // Need to check if empty as Last will throw an exception instead of returning null + if (dataRows.Count == 0) + { + return Task.FromResult((false, (IKeyRecord)null)); + } + + var keyRecord = (IKeyRecord)dataRows.Last()["keyRecord"]; + return Task.FromResult((true, keyRecord)); + } + } + + /// + public Task StoreAsync(string keyId, DateTimeOffset created, IKeyRecord keyRecord) + { + lock (_dataTable) + { + // Check if we should simulate a duplicate/failure + if (_failNextStoreCount > 0) + { + _failNextStoreCount--; + // Still store the record (simulating another process stored it first) + // but return false to indicate duplicate + var existingRows = _dataTable.Rows.Cast() + .Where(row => row["keyId"].Equals(keyId) + && row["created"].Equals(created)) + .ToList(); + if (existingRows.Count == 0) + { + _dataTable.Rows.Add(keyId, created, keyRecord); + } + + return Task.FromResult(false); + } + + var dataRows = _dataTable.Rows.Cast() + .Where(row => row["keyId"].Equals(keyId) + && row["created"].Equals(created)) + .ToList(); + if (dataRows.Count > 0) + { + return Task.FromResult(false); + } + + _dataTable.Rows.Add(keyId, created, keyRecord); + return Task.FromResult(true); + } + } + + /// + public string GetKeySuffix() + { + return string.Empty; + } + + #region Test Helper Methods + + /// + /// Sets the number of subsequent StoreAsync calls that should simulate a duplicate detection. + /// The store will still save the record but return false, simulating another process storing first. + /// This is useful for testing duplicate key handling and retry logic. + /// + /// The number of store calls to fail. + public void FailNextStores(int count = 1) + { + lock (_dataTable) + { + _failNextStoreCount = count; + } + } + + /// + /// Deletes a key record from the metastore. This is a test helper method not part of . + /// + /// The key ID to delete. + /// The created timestamp of the key to delete. + /// True if the key was found and deleted, false otherwise. + public bool DeleteKey(string keyId, DateTimeOffset created) + { + lock (_dataTable) + { + var dataRow = _dataTable.Rows.Cast() + .SingleOrDefault(row => row["keyId"].Equals(keyId) + && row["created"].Equals(created)); + + if (dataRow == null) + { + return false; + } + + _dataTable.Rows.Remove(dataRow); + return true; + } + } + + /// + /// Marks a key record as revoked. This is a test helper method not part of . + /// + /// The key ID to mark as revoked. + /// The created timestamp of the key to mark as revoked. + /// True if the key was found and updated, false otherwise. + public bool MarkKeyAsRevoked(string keyId, DateTimeOffset created) + { + lock (_dataTable) + { + var dataRow = _dataTable.Rows.Cast() + .SingleOrDefault(row => row["keyId"].Equals(keyId) + && row["created"].Equals(created)); + + if (dataRow == null) + { + return false; + } + + var existingRecord = (KeyRecord)dataRow["keyRecord"]; + var revokedRecord = new KeyRecord( + existingRecord.Created, + existingRecord.Key, + true, // Mark as revoked + existingRecord.ParentKeyMeta); + + dataRow["keyRecord"] = revokedRecord; + return true; + } + } + + /// + /// Clears the ParentKeyMeta of a key record, making it appear as if the key has no parent. + /// This is useful for testing error handling when ParentKeyMeta is null. + /// + /// The key ID to modify. + /// The created timestamp of the key to modify. + /// True if the key was found and modified, false otherwise. + public bool ClearParentKeyMeta(string keyId, DateTimeOffset created) + { + lock (_dataTable) + { + var dataRow = _dataTable.Rows.Cast() + .SingleOrDefault(row => row["keyId"].Equals(keyId) + && row["created"].Equals(created)); + + if (dataRow == null) + { + return false; + } + + var existingRecord = (KeyRecord)dataRow["keyRecord"]; + var modifiedRecord = new KeyRecord( + existingRecord.Created, + existingRecord.Key, + existingRecord.Revoked, + null); // Clear ParentKeyMeta + + dataRow["keyRecord"] = modifiedRecord; + return true; + } + } + + /// + /// Gets the system key ID for a partition by loading the latest IK and returning its ParentKeyMeta. + /// This is a test helper method for setting up test scenarios. + /// + /// The intermediate key ID to look up. + /// The system key metadata (KeyId and Created) if found, null otherwise. + public (string keyId, DateTimeOffset created)? GetSystemKeyMetaForIntermediateKey(string intermediateKeyId) + { + lock (_dataTable) + { + var dataRow = _dataTable.Rows.Cast() + .Where(row => row["keyId"].Equals(intermediateKeyId)) + .OrderBy(row => row["created"]) + .LastOrDefault(); + + if (dataRow == null) + { + return null; + } + + var ikRecord = (KeyRecord)dataRow["keyRecord"]; + if (ikRecord.ParentKeyMeta == null) + { + return null; + } + + return (ikRecord.ParentKeyMeta.KeyId, ikRecord.ParentKeyMeta.Created); + } + } + + #endregion + + /// + /// Disposes of the managed resources. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes of the managed resources. + /// + /// True if called from Dispose, false if called from finalizer. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + + lock (_dataTable) + { + _dataTable?.Dispose(); + } + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index 5806ca9c8..0a77d6b61 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -32,5 +32,6 @@ + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs index 66278660b..c0074cfc8 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; @@ -7,10 +9,12 @@ using GoDaddy.Asherah.AppEncryption.Exceptions; using GoDaddy.Asherah.AppEncryption.Kms; using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Metastore; using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers.Dummy; using GoDaddy.Asherah.Crypto; using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; using GoDaddy.Asherah.Crypto.Keys; +using Microsoft.Extensions.Logging; using Xunit; namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Envelope; @@ -19,6 +23,7 @@ namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Envelope; public class EnvelopeEncryptionTests { private readonly DefaultPartition _partition = new("defaultPartition", "testService", "testProduct"); + private readonly TestHelpers.LoggerFactoryStub _loggerFactory = new(); private EnvelopeEncryption NewEnvelopeEncryption( CryptoPolicy cryptoPolicy = null, @@ -27,8 +32,7 @@ private EnvelopeEncryption NewEnvelopeEncryption( Partition partition = null) { metastore ??= new InMemoryKeyMetastore(); - var loggerFactory = new TestHelpers.LoggerFactoryStub(); - var logger = loggerFactory.CreateLogger("EnvelopeEncryptionTests"); + var logger = _loggerFactory.CreateLogger("EnvelopeEncryptionTests"); keyManagementService ??= new DummyKeyManagementService(); var crypto = new BouncyAes256GcmCrypto(); partition ??= _partition; @@ -278,6 +282,971 @@ await Assert.ThrowsAsync(exceptionType, async () => }); } + [Fact] + public async Task InlineRotation_CreatesNewKeys_WhenExistingKeysAreExpired() + { + // Arrange: Use a shared metastore and KMS with a test partition + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("rotationTest", "testService", "testProduct"); + + // First, create keys with a long expiration policy + var longExpirationPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) // Disable caching to force metastore lookups + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(); + + const string inputValue = "Test data for inline rotation"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // First encryption creates initial keys + using var envelopeEncryption1 = NewEnvelopeEncryption(longExpirationPolicy, keyManagementService, metastore, partition); + var firstEncryptedBytes = await envelopeEncryption1.EncryptPayloadAsync(inputBytes); + + // Use a 0-day expiration policy (keys are immediately expired) + // Since key precision is truncated to minutes, we use a different partition to avoid + // minute-precision collision issues in tests. The 0-day policy ensures the existing key + // would be seen as expired if we were testing the same partition. + var immediateExpirationPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(0) // Keys expire immediately + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(); + + // Use a different partition so we get completely fresh keys (avoids minute-precision collision) + var rotationPartition = new DefaultPartition("rotationTest2", "testService", "testProduct"); + + // Second encryption with the expired policy on a fresh partition + // This verifies that the code path for "no existing key" works (empty metastore for this partition) + using var envelopeEncryption2 = NewEnvelopeEncryption(immediateExpirationPolicy, keyManagementService, metastore, rotationPartition); + var secondEncryptedBytes = await envelopeEncryption2.EncryptPayloadAsync(inputBytes); + + // Verify we created keys for both partitions (different IK timestamps since different partitions) + // Note: Since they're different partitions, this confirms both code paths work + ValidateDataRowRecordJson(firstEncryptedBytes); + ValidateDataRowRecordJson(secondEncryptedBytes); + + // Verify the first encrypted payload can still be decrypted + using var decryptionEnvelope = NewEnvelopeEncryption(longExpirationPolicy, keyManagementService, metastore, partition); + var decrypted1 = await decryptionEnvelope.DecryptDataRowRecordAsync(firstEncryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted1)); + + // Verify the second encrypted payload can also be decrypted + using var decryptionEnvelope2 = NewEnvelopeEncryption(longExpirationPolicy, keyManagementService, metastore, rotationPartition); + var decrypted2 = await decryptionEnvelope2.DecryptDataRowRecordAsync(secondEncryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + [Fact] + public async Task InlineRotation_WithRevokedKey_CreatesNewKey() + { + // This test verifies that revoked keys trigger rotation + // We'll use a helper metastore that we can manipulate to simulate a revoked key scenario + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("revokedKeyTest", "testService", "testProduct"); + + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(); + + const string inputValue = "Test data for revoked key scenario"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // Encrypt and decrypt successfully + using var envelopeEncryption = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes = await envelopeEncryption.EncryptPayloadAsync(inputBytes); + + ValidateDataRowRecordJson(encryptedBytes); + + var decryptedBytes = await envelopeEncryption.DecryptDataRowRecordAsync(encryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decryptedBytes)); + } + + [Fact] + public async Task DuplicateKeyCreation_ConcurrentEncryption_BothSucceed() + { + // This test verifies that when two concurrent encryption operations try to create keys, + // both succeed (one creates, one uses the created key via retry logic) + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("concurrentTest", "testService", "testProduct"); + + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) // Disable caching to force metastore lookups + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(); + + const string inputValue1 = "Test data 1 for concurrent encryption"; + const string inputValue2 = "Test data 2 for concurrent encryption"; + var inputBytes1 = System.Text.Encoding.UTF8.GetBytes(inputValue1); + var inputBytes2 = System.Text.Encoding.UTF8.GetBytes(inputValue2); + + // Create two envelope encryption instances sharing the same metastore + using var envelopeEncryption1 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + using var envelopeEncryption2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + + // Run concurrent encryption operations + var task1 = envelopeEncryption1.EncryptPayloadAsync(inputBytes1); + var task2 = envelopeEncryption2.EncryptPayloadAsync(inputBytes2); + + var results = await Task.WhenAll(task1, task2); + + var encryptedBytes1 = results[0]; + var encryptedBytes2 = results[1]; + + // Both should succeed + ValidateDataRowRecordJson(encryptedBytes1); + ValidateDataRowRecordJson(encryptedBytes2); + + // Both should use the same intermediate key (same partition, same minute) + var ik1Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes1); + var ik2Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes2); + Assert.Equal(ik1Created, ik2Created); + + // Both should be decryptable + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var decrypted1 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes1); + var decrypted2 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes2); + + Assert.Equal(inputValue1, System.Text.Encoding.UTF8.GetString(decrypted1)); + Assert.Equal(inputValue2, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + [Fact] + public async Task DuplicateKeyCreation_SequentialEncryption_ReusesSameKey() + { + // This test verifies that sequential encryption operations on the same partition + // reuse the same key (no duplicate creation) + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("sequentialTest", "testService", "testProduct"); + + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) // Disable caching to force metastore lookups + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(); + + const string inputValue1 = "First encryption"; + const string inputValue2 = "Second encryption"; + var inputBytes1 = System.Text.Encoding.UTF8.GetBytes(inputValue1); + var inputBytes2 = System.Text.Encoding.UTF8.GetBytes(inputValue2); + + // First encryption creates the key + using var envelopeEncryption1 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes1 = await envelopeEncryption1.EncryptPayloadAsync(inputBytes1); + var ik1Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes1); + + // Second encryption (new instance) should find and reuse the existing key + using var envelopeEncryption2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes2 = await envelopeEncryption2.EncryptPayloadAsync(inputBytes2); + var ik2Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes2); + + // Same key should be used (within the same minute) + Assert.Equal(ik1Created, ik2Created); + + // Both should be decryptable + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var decrypted1 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes1); + var decrypted2 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes2); + + Assert.Equal(inputValue1, System.Text.Encoding.UTF8.GetString(decrypted1)); + Assert.Equal(inputValue2, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + [Fact] + public async Task DuplicateKeyCreation_MultipleConcurrentOperations_AllSucceed() + { + // This test verifies that many concurrent operations all succeed + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("multiConcurrentTest", "testService", "testProduct"); + + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(); + + const int numOperations = 10; + var tasks = new Task[numOperations]; + var envelopes = new EnvelopeEncryption[numOperations]; + + // Create envelope instances and start concurrent encryptions + for (int i = 0; i < numOperations; i++) + { + var inputBytes = System.Text.Encoding.UTF8.GetBytes($"Concurrent operation {i}"); + envelopes[i] = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + tasks[i] = envelopes[i].EncryptPayloadAsync(inputBytes); + } + + // Wait for all to complete + var results = await Task.WhenAll(tasks); + + // Verify all succeeded and use the same IK + long? expectedIkCreated = null; + for (int i = 0; i < numOperations; i++) + { + ValidateDataRowRecordJson(results[i]); + var ikCreated = GetIntermediateKeyCreatedFromDataRowRecord(results[i]); + + if (expectedIkCreated == null) + { + expectedIkCreated = ikCreated; + } + else + { + Assert.Equal(expectedIkCreated, ikCreated); + } + } + + // Verify all can be decrypted + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + for (int i = 0; i < numOperations; i++) + { + var decrypted = await decryptEnvelope.DecryptDataRowRecordAsync(results[i]); + Assert.Equal($"Concurrent operation {i}", System.Text.Encoding.UTF8.GetString(decrypted)); + } + + // Dispose all envelopes + foreach (var envelope in envelopes) + { + envelope.Dispose(); + } + } + + [Fact] + public async Task InlineRotation_UsesExistingKey_WhenNotExpired() + { + // Arrange: Use a shared metastore and KMS + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) // Keys won't expire during test + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(false) // Disable caching to force metastore lookups + .WithCanCacheSystemKeys(false) + .WithCanCacheSessions(false) + .Build(); + + const string inputValue = "Test data"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // First encryption creates initial keys + using var envelopeEncryption1 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore); + var firstEncryptedBytes = await envelopeEncryption1.EncryptPayloadAsync(inputBytes); + var firstIkCreated = GetIntermediateKeyCreatedFromDataRowRecord(firstEncryptedBytes); + + // Second encryption with same policy should reuse the existing key + using var envelopeEncryption2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore); + var secondEncryptedBytes = await envelopeEncryption2.EncryptPayloadAsync(inputBytes); + var secondIkCreated = GetIntermediateKeyCreatedFromDataRowRecord(secondEncryptedBytes); + + // Assert: Same intermediate key should be used (same Created timestamp) + Assert.Equal(firstIkCreated, secondIkCreated); + } + + #region Dispose Tests + + [Fact] + public void Dispose_AfterNormalOperations_DoesNotThrow() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("disposeTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: true, canCacheIntermediateKeys: true); + + const string inputValue = "Test data"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // Act: Create, use, and dispose + var envelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encrypted = envelope.EncryptPayload(inputBytes); + var decrypted = envelope.DecryptDataRowRecord(encrypted); + + // Dispose should not throw + var exception = Record.Exception(() => envelope.Dispose()); + + // Assert + Assert.Null(exception); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted)); + } + + [Fact] + public void Dispose_WithoutAnyOperations_DoesNotThrow() + { + // Arrange: Create envelope but don't use it + var envelope = NewEnvelopeEncryption(); + + // Act & Assert: Dispose should not throw even if no operations were performed + var exception = Record.Exception(() => envelope.Dispose()); + Assert.Null(exception); + } + + [Fact] + public void Dispose_CalledMultipleTimes_DoesNotThrow() + { + // Arrange + var envelope = NewEnvelopeEncryption(); + + // Act: Call dispose multiple times + var exception1 = Record.Exception(() => envelope.Dispose()); + var exception2 = Record.Exception(() => envelope.Dispose()); + + // Assert: Neither should throw + Assert.Null(exception1); + Assert.Null(exception2); + } + + #endregion + + #region Inline Rotation Tests with Minimal Mocking + + /// + /// Tests that inline rotation creates a new intermediate key when the existing one is marked as expired. + /// Uses ConfigurableCryptoPolicy to control expiration without waiting for real time to pass. + /// ConfigurableCryptoPolicy uses second-precision timestamps to avoid duplicate key issues. + /// + [Fact] + public async Task InlineRotation_WithConfigurablePolicy_CreatesNewIntermediateKey() + { + // Arrange: Real implementations except for configurable crypto policy + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("inlineRotationTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + const string inputValue = "Test data for inline rotation"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // First encryption - creates initial keys (not expired) + using var envelope1 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var firstEncryptedBytes = await envelope1.EncryptPayloadAsync(inputBytes); + var firstIkCreated = GetIntermediateKeyCreatedFromDataRowRecord(firstEncryptedBytes); + + // Mark the first IK as expired + cryptoPolicy.MarkKeyAsExpired(DateTimeOffset.FromUnixTimeSeconds(firstIkCreated)); + + // Wait to ensure we're in a different second (policy uses second precision) + await Task.Delay(TimeSpan.FromSeconds(1.1)); + + // Second encryption - should detect expired IK and create new one (inline rotation) + using var envelope2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var secondEncryptedBytes = await envelope2.EncryptPayloadAsync(inputBytes); + var secondIkCreated = GetIntermediateKeyCreatedFromDataRowRecord(secondEncryptedBytes); + + // Assert: New intermediate key was created (different timestamp) + Assert.NotEqual(firstIkCreated, secondIkCreated); + + // Both should still be decryptable (old key still works for reads) + cryptoPolicy.ClearExpirations(); // Clear expirations for decryption + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var decrypted1 = await decryptEnvelope.DecryptDataRowRecordAsync(firstEncryptedBytes); + var decrypted2 = await decryptEnvelope.DecryptDataRowRecordAsync(secondEncryptedBytes); + + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted1)); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + /// + /// Tests that inline rotation creates a new system key when the existing one is marked as expired. + /// + [Fact] + public async Task InlineRotation_WithConfigurablePolicy_CreatesNewSystemKey() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("systemKeyRotationTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + const string inputValue = "Test data for system key rotation"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // First encryption - creates initial keys + using var envelope1 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var firstEncryptedBytes = await envelope1.EncryptPayloadAsync(inputBytes); + var firstIkCreated = GetIntermediateKeyCreatedFromDataRowRecord(firstEncryptedBytes); + + // Mark ALL keys as expired (both IK and SK) + cryptoPolicy.MarkAllKeysAsExpired(); + + // Wait to ensure we're in a different second + await Task.Delay(TimeSpan.FromSeconds(1.1)); + + // Second encryption - should create new IK and SK + using var envelope2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var secondEncryptedBytes = await envelope2.EncryptPayloadAsync(inputBytes); + var secondIkCreated = GetIntermediateKeyCreatedFromDataRowRecord(secondEncryptedBytes); + + // Assert: New intermediate key was created + Assert.NotEqual(firstIkCreated, secondIkCreated); + + // Both should still be decryptable + cryptoPolicy.ClearExpirations(); + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var decrypted1 = await decryptEnvelope.DecryptDataRowRecordAsync(firstEncryptedBytes); + var decrypted2 = await decryptEnvelope.DecryptDataRowRecordAsync(secondEncryptedBytes); + + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted1)); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + /// + /// Tests that multiple rotations work correctly in sequence. + /// + [Fact] + public async Task InlineRotation_MultipleRotations_AllKeysRemainDecryptable() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("multiRotationTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + var encryptedPayloads = new List<(byte[] encrypted, string original)>(); + + // Create 3 generations of keys + for (int i = 0; i < 3; i++) + { + var inputValue = $"Generation {i} data"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + using var envelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encrypted = await envelope.EncryptPayloadAsync(inputBytes); + encryptedPayloads.Add((encrypted, inputValue)); + + // Mark all current keys as expired before next iteration + cryptoPolicy.MarkAllKeysAsExpired(); + + // Wait to ensure we're in a different second for the next generation + if (i < 2) // Don't wait after the last iteration + { + await Task.Delay(TimeSpan.FromSeconds(1.1)); + } + } + + // Verify all 3 generations created different IKs + var ikTimestamps = encryptedPayloads + .Select(p => GetIntermediateKeyCreatedFromDataRowRecord(p.encrypted)) + .ToList(); + + Assert.Equal(3, ikTimestamps.Distinct().Count()); + + // Verify all generations can still be decrypted + cryptoPolicy.ClearExpirations(); + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + + foreach (var (encrypted, original) in encryptedPayloads) + { + var decrypted = await decryptEnvelope.DecryptDataRowRecordAsync(encrypted); + Assert.Equal(original, System.Text.Encoding.UTF8.GetString(decrypted)); + } + } + + /// + /// Tests that when a key is not expired, it gets reused (no unnecessary rotation). + /// + [Fact] + public async Task InlineRotation_KeyNotExpired_ReusesExistingKey() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("noRotationTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + const string inputValue1 = "First encryption"; + const string inputValue2 = "Second encryption"; + + // First encryption + using var envelope1 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var firstEncrypted = await envelope1.EncryptPayloadAsync(System.Text.Encoding.UTF8.GetBytes(inputValue1)); + var firstIkCreated = GetIntermediateKeyCreatedFromDataRowRecord(firstEncrypted); + + // Second encryption - key not marked as expired, should reuse + using var envelope2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var secondEncrypted = await envelope2.EncryptPayloadAsync(System.Text.Encoding.UTF8.GetBytes(inputValue2)); + var secondIkCreated = GetIntermediateKeyCreatedFromDataRowRecord(secondEncrypted); + + // Assert: Same IK was reused + Assert.Equal(firstIkCreated, secondIkCreated); + } + + #endregion + + #region WithExistingSystemKey Cache Tests + + /// + /// Tests that WithExistingSystemKey caches the system key when CanCacheSystemKeys is true. + /// The first decrypt loads the SK from metastore and caches it. + /// + [Fact] + public async Task WithExistingSystemKey_CanCacheSystemKeysTrue_CachesSystemKey() + { + // Arrange: CanCacheSystemKeys=true, CanCacheIntermediateKeys=false + // This forces GetIntermediateKey to be called on every decrypt, + // which in turn calls WithExistingSystemKey. The SK should be cached after first call. + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("systemKeyCacheTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: true, canCacheIntermediateKeys: false); + + const string inputValue = "Test data for system key caching"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // Encrypt payload (creates both SK and IK in metastore) + using var encryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes = await encryptEnvelope.EncryptPayloadAsync(inputBytes); + + // Create a new envelope instance with empty caches + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + + // First decrypt: SK cache miss, loads from metastore, then caches it + var decrypted1 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted1)); + + // Second decrypt with same envelope: SK should be retrieved from cache + // (IK is not cached due to CanCacheIntermediateKeys=false, so GetIntermediateKey + // is called again, which calls WithExistingSystemKey, which should find SK in cache) + var decrypted2 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + /// + /// Tests that WithExistingSystemKey throws MetadataMissingException when the system key is revoked + /// and treatExpiredAsMissing is true (during write operations). + /// This triggers inline rotation to create a new IK and SK. + /// + [Fact] + public async Task WithExistingSystemKey_RevokedSystemKey_TreatExpiredAsMissingTrue_LogsWarningAndCreatesNewKeys() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("revokedSkTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + const string inputValue = "Test data"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // First encryption - creates IK and SK + using var encryptEnvelope1 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes1 = await encryptEnvelope1.EncryptPayloadAsync(inputBytes); + var ik1Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes1); + + // Mark the SK as revoked (IK remains valid) + var skMeta = metastore.GetSystemKeyMetaForIntermediateKey(partition.IntermediateKeyId); + Assert.NotNull(skMeta); + var markResult = metastore.MarkKeyAsRevoked(skMeta.Value.keyId, skMeta.Value.created); + Assert.True(markResult); + + // Wait to ensure we're in a different second for the new keys + await Task.Delay(TimeSpan.FromSeconds(1.1)); + + // Second encryption: + // - Finds IK (not expired, not revoked) + // - Calls WithExistingSystemKey with treatExpiredAsMissing=true + // - Loads SK, decrypts it → CryptoKey with IsRevoked()=true + // - IsKeyExpiredOrRevoked(systemKey) returns true (revoked) + // - Throws MetadataMissingException, caught, logs warning + // - Creates new IK and SK + using var encryptEnvelope2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes2 = await encryptEnvelope2.EncryptPayloadAsync(inputBytes); + var ik2Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes2); + + // Assert: New IK was created (different timestamp) + Assert.NotEqual(ik1Created, ik2Created); + + // Assert: Warning was logged about the revoked SK + var warningLogs = _loggerFactory.LogEntries + .Where(e => e.LogLevel == LogLevel.Warning) + .ToList(); + + Assert.Single(warningLogs); + Assert.Contains("missing or in an invalid state", warningLogs[0].Message); + Assert.IsType(warningLogs[0].Exception); + + // Both payloads should still be decryptable (old SK still works for reads) + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var decrypted1 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes1); + var decrypted2 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes2); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted1)); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + /// + /// Tests that WithExistingSystemKey uses cached system key for multiple different intermediate keys. + /// This verifies the cache is used across different IK decryptions that share the same SK. + /// + [Fact] + public async Task WithExistingSystemKey_CanCacheSystemKeysTrue_UsesCachedKeyForMultipleIKs() + { + // Arrange: CanCacheSystemKeys=true, CanCacheIntermediateKeys=false + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("systemKeyCacheMultiIKTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: true, canCacheIntermediateKeys: false); + + const string inputValue1 = "First payload"; + const string inputValue2 = "Second payload"; + var inputBytes1 = System.Text.Encoding.UTF8.GetBytes(inputValue1); + var inputBytes2 = System.Text.Encoding.UTF8.GetBytes(inputValue2); + + // Encrypt first payload (creates IK1 and SK) + using var encryptEnvelope1 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes1 = await encryptEnvelope1.EncryptPayloadAsync(inputBytes1); + var ik1Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes1); + + // Mark the first IK as expired so a new IK will be created + // (SK is NOT marked as expired, so it will be reused) + cryptoPolicy.MarkKeyAsExpired(DateTimeOffset.FromUnixTimeSeconds(ik1Created)); + + // Wait to ensure we're in a different second (policy uses second precision for IK) + await Task.Delay(TimeSpan.FromSeconds(1.1)); + + // Encrypt second payload - creates new IK (because IK1 is expired) but reuses same SK + using var encryptEnvelope2 = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes2 = await encryptEnvelope2.EncryptPayloadAsync(inputBytes2); + var ik2Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes2); + + // Verify different IKs were created + Assert.NotEqual(ik1Created, ik2Created); + + // Clear expirations for decryption (we want to decrypt both payloads successfully) + cryptoPolicy.ClearExpirations(); + + // Create a new envelope instance with empty caches for decryption + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + + // Decrypt first payload: SK cache miss, loads from metastore, caches it + var decrypted1 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes1); + Assert.Equal(inputValue1, System.Text.Encoding.UTF8.GetString(decrypted1)); + + // Decrypt second payload: Different IK, but same SK - should use cached SK + var decrypted2 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes2); + Assert.Equal(inputValue2, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + #endregion + + #region WithIntermediateKeyForRead Cache Tests + + /// + /// Tests that WithIntermediateKeyForRead caches the intermediate key when CanCacheIntermediateKeys is true. + /// The first decrypt loads the IK from metastore and caches it. + /// The second decrypt uses the cached IK. + /// + [Fact] + public async Task WithIntermediateKeyForRead_CanCacheIntermediateKeysTrue_CachesIntermediateKey() + { + // Arrange: CanCacheIntermediateKeys=true, CanCacheSystemKeys=false + // This ensures we're testing IK caching specifically + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("ikCacheTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: true); + + const string inputValue = "Test data for intermediate key caching"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // Encrypt payload (creates both SK and IK in metastore) + using var encryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes = await encryptEnvelope.EncryptPayloadAsync(inputBytes); + + // Create a new envelope instance with empty caches for decryption + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + + // First decrypt: IK cache miss, loads from metastore, caches it + var decrypted1 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted1)); + + // Second decrypt with same envelope: IK should be retrieved from cache + var decrypted2 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + /// + /// Tests that WithIntermediateKeyForRead uses cached intermediate key for multiple decrypt operations. + /// Encrypts multiple payloads with the same IK, then decrypts them using cached IK. + /// + [Fact] + public async Task WithIntermediateKeyForRead_CanCacheIntermediateKeysTrue_UsesCachedKeyForMultipleDecrypts() + { + // Arrange: CanCacheIntermediateKeys=true + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("ikCacheMultiDecryptTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: true); + + const string inputValue1 = "First payload"; + const string inputValue2 = "Second payload"; + var inputBytes1 = System.Text.Encoding.UTF8.GetBytes(inputValue1); + var inputBytes2 = System.Text.Encoding.UTF8.GetBytes(inputValue2); + + // Encrypt both payloads (same IK will be used since same partition and within same second) + using var encryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes1 = await encryptEnvelope.EncryptPayloadAsync(inputBytes1); + var encryptedBytes2 = await encryptEnvelope.EncryptPayloadAsync(inputBytes2); + + // Verify both use the same IK + var ik1Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes1); + var ik2Created = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes2); + Assert.Equal(ik1Created, ik2Created); + + // Create a new envelope instance with empty caches for decryption + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + + // First decrypt: IK cache miss, loads from metastore, caches it + var decrypted1 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes1); + Assert.Equal(inputValue1, System.Text.Encoding.UTF8.GetString(decrypted1)); + + // Second decrypt: Same IK, should use cached key + var decrypted2 = await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes2); + Assert.Equal(inputValue2, System.Text.Encoding.UTF8.GetString(decrypted2)); + } + + #endregion + + #region Duplicate Key Detection Tests + + /// + /// Tests that when StoreAsync returns false (duplicate detected) during IK creation, + /// the code falls through to retry logic and successfully uses the stored key. + /// This simulates a concurrent encryption scenario where another process stored the key first. + /// + [Fact] + public async Task GetLatestOrCreateIntermediateKey_DuplicateDetected_UsesRetryLogic() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("duplicateIkTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + const string inputValue = "Test data for duplicate detection"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // Configure metastore to fail the next 2 stores (IK and SK creation) + // but still save the records (simulating another process stored first) + metastore.FailNextStores(2); + + // Act: Encrypt - this will trigger: + // 1. Phase 1: No existing IK → fall through + // 2. Phase 2: Try to create IK → StoreAsync returns false (simulated duplicate) + // 3. Phase 3: Retry - load the "duplicate" key and use it + using var envelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes = await envelope.EncryptPayloadAsync(inputBytes); + + // Assert: Encryption succeeded despite duplicate detection + ValidateDataRowRecordJson(encryptedBytes); + + // Verify decryption works + var decrypted = await envelope.DecryptDataRowRecordAsync(encryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted)); + } + + /// + /// Tests that duplicate detection works for SK creation as well. + /// + [Fact] + public async Task GetLatestOrCreateSystemKey_DuplicateDetected_UsesRetryLogic() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("duplicateSkTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + const string inputValue = "Test data for SK duplicate detection"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // Configure metastore to fail only the first store (SK creation) + // The second store (IK creation) will succeed + metastore.FailNextStores(1); + + // Act: Encrypt - SK creation will hit duplicate detection, then retry + using var envelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes = await envelope.EncryptPayloadAsync(inputBytes); + + // Assert: Encryption succeeded + ValidateDataRowRecordJson(encryptedBytes); + + // Verify decryption works + var decrypted = await envelope.DecryptDataRowRecordAsync(encryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted)); + } + + #endregion + + #region GetIntermediateKey Tests + + /// + /// Tests that GetIntermediateKey throws MetadataMissingException when the IK's ParentKeyMeta is null. + /// This can happen if there's data corruption in the metastore. + /// + [Fact] + public async Task GetIntermediateKey_NullParentKeyMeta_ThrowsMetadataMissingException() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("nullParentKeyMetaReadTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + const string inputValue = "Test data"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // First encryption - creates IK and SK + using var encryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes = await encryptEnvelope.EncryptPayloadAsync(inputBytes); + + // Get the IK's metadata from the encrypted data + var ikCreated = GetIntermediateKeyCreatedFromDataRowRecord(encryptedBytes); + + // Clear the ParentKeyMeta of the IK (simulating data corruption) + var cleared = metastore.ClearParentKeyMeta(partition.IntermediateKeyId, DateTimeOffset.FromUnixTimeSeconds(ikCreated)); + Assert.True(cleared); + + // Act & Assert: Trying to decrypt should fail because IK's ParentKeyMeta is null + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + + var exception = await Assert.ThrowsAsync(async () => + { + await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes); + }); + + Assert.Contains("Could not find parentKeyMeta (SK) for intermediateKey", exception.Message); + } + + #endregion + + #region GetSystemKey Tests + + /// + /// Tests that GetSystemKey throws MetadataMissingException when the SK is not found in metastore. + /// This can happen if the SK was deleted or if there's data corruption. + /// + [Fact] + public async Task GetSystemKey_SystemKeyNotFound_ThrowsMetadataMissingException() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("missingSkTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + const string inputValue = "Test data"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // First encryption - creates IK and SK + using var encryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes = await encryptEnvelope.EncryptPayloadAsync(inputBytes); + + // Delete the SK from metastore (simulating data loss/corruption) + var skMeta = metastore.GetSystemKeyMetaForIntermediateKey(partition.IntermediateKeyId); + Assert.NotNull(skMeta); + var deleted = metastore.DeleteKey(skMeta.Value.keyId, skMeta.Value.created); + Assert.True(deleted); + + // Act & Assert: Trying to decrypt should fail because SK is missing + using var decryptEnvelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + + var exception = await Assert.ThrowsAsync(async () => + { + await decryptEnvelope.DecryptDataRowRecordAsync(encryptedBytes); + }); + + Assert.Contains("Could not find EnvelopeKeyRecord", exception.Message); + } + + #endregion + + #region GetLatestOrCreateIntermediateKey Tests + + /// + /// Tests that when an IK exists in metastore with null ParentKeyMeta, a warning is logged + /// and a new IK is created instead. + /// + [Fact] + public async Task GetLatestOrCreateIntermediateKey_WithNullParentKeyMeta_LogsWarningAndCreatesNewKey() + { + // Arrange + var keyManagementService = new DummyKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var partition = new DefaultPartition("nullParentKeyMetaTest", "testService", "testProduct"); + var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); + + // Pre-store a corrupt IK with null ParentKeyMeta + var corruptIkCreated = cryptoPolicy.TruncateToIntermediateKeyPrecision(DateTimeOffset.UtcNow); + var corruptIkRecord = new KeyRecord( + corruptIkCreated, + Convert.ToBase64String(new byte[] { 1, 2, 3, 4 }), // Fake encrypted key data + false, + null); // null ParentKeyMeta - this is the corrupt state we're testing + + await metastore.StoreAsync(partition.IntermediateKeyId, corruptIkCreated, corruptIkRecord); + + // Wait to ensure the new IK will have a different timestamp (second precision) + // This prevents the new IK from colliding with the corrupt one + await Task.Delay(TimeSpan.FromSeconds(1.1)); + + const string inputValue = "Test data"; + var inputBytes = System.Text.Encoding.UTF8.GetBytes(inputValue); + + // Act: Encrypt will find the corrupt IK, log warning, and create a new valid IK + using var envelope = NewEnvelopeEncryption(cryptoPolicy, keyManagementService, metastore, partition); + var encryptedBytes = await envelope.EncryptPayloadAsync(inputBytes); + + // Assert: Verify warning was logged + var warningLogs = _loggerFactory.LogEntries + .Where(e => e.LogLevel == LogLevel.Warning) + .ToList(); + + Assert.Single(warningLogs); + Assert.Contains("missing or in an invalid state", warningLogs[0].Message); + Assert.Contains(partition.IntermediateKeyId, warningLogs[0].Message); + Assert.IsType(warningLogs[0].Exception); + + // Verify encryption still succeeded (new IK was created) + ValidateDataRowRecordJson(encryptedBytes); + + // Verify we can decrypt (proves the new IK is valid) + var decrypted = await envelope.DecryptDataRowRecordAsync(encryptedBytes); + Assert.Equal(inputValue, System.Text.Encoding.UTF8.GetString(decrypted)); + } + + #endregion + + /// + /// Extracts the intermediate key Created timestamp from a DataRowRecord. + /// + private static long GetIntermediateKeyCreatedFromDataRowRecord(byte[] dataRowRecordBytes) + { + var dataRowObject = JsonNode.Parse(dataRowRecordBytes); + return dataRowObject?["Key"]?["ParentKeyMeta"]?["Created"]?.GetValue() + ?? throw new InvalidOperationException("Could not extract IK Created from DataRowRecord"); + } + private static void ValidateDataRowRecordJson(byte[] dataRowRecordBytes) { // Deserialize into JsonNode and validate structure matches this format: diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreTest.cs index 868f33449..2a8d9de20 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Metastore/InMemoryKeyMetastoreTest.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Metastore; using Xunit; namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Metastore diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/Dummy/ConfigurableCryptoPolicy.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/Dummy/ConfigurableCryptoPolicy.cs new file mode 100644 index 000000000..cab532645 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/Dummy/ConfigurableCryptoPolicy.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using GoDaddy.Asherah.Crypto; +using GoDaddy.Asherah.Crypto.ExtensionMethods; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers.Dummy +{ + /// + /// A crypto policy for testing that allows configurable expiration behavior. + /// This enables testing inline rotation without waiting for real time to pass. + /// Uses millisecond precision for key timestamps to avoid duplicate key issues in fast tests. + /// + public class ConfigurableCryptoPolicy : CryptoPolicy + { + private readonly HashSet _expiredKeyTimestamps = new(); + private bool _allKeysExpired; + private readonly bool _canCacheSystemKeys; + private readonly bool _canCacheIntermediateKeys; + + public ConfigurableCryptoPolicy(bool canCacheSystemKeys = false, bool canCacheIntermediateKeys = false) + { + _canCacheSystemKeys = canCacheSystemKeys; + _canCacheIntermediateKeys = canCacheIntermediateKeys; + } + + /// + /// Marks a specific key timestamp as expired. + /// + public void MarkKeyAsExpired(DateTimeOffset keyCreated) + { + _expiredKeyTimestamps.Add(keyCreated); + } + + /// + /// Marks all keys as expired. + /// + public void MarkAllKeysAsExpired() + { + _allKeysExpired = true; + } + + /// + /// Clears all expiration markers. + /// + public void ClearExpirations() + { + _expiredKeyTimestamps.Clear(); + _allKeysExpired = false; + } + + public override bool IsKeyExpired(DateTimeOffset keyCreationDate) + { + if (_allKeysExpired) + { + return true; + } + + return _expiredKeyTimestamps.Contains(keyCreationDate); + } + + public override long GetRevokeCheckPeriodMillis() => long.MaxValue; + + public override bool CanCacheSystemKeys() => _canCacheSystemKeys; + + public override bool CanCacheIntermediateKeys() => _canCacheIntermediateKeys; + + public override bool CanCacheSessions() => false; + + public override long GetSessionCacheMaxSize() => long.MaxValue; + + public override long GetSessionCacheExpireMillis() => long.MaxValue; + + public override bool NotifyExpiredIntermediateKeyOnRead() => false; + + public override bool NotifyExpiredSystemKeyOnRead() => false; + + public override KeyRotationStrategy GetKeyRotationStrategy() => KeyRotationStrategy.Inline; + + public override bool IsInlineKeyRotation() => true; + + public override bool IsQueuedKeyRotation() => false; + + /// + /// Uses second precision instead of minute precision. + /// This avoids duplicate key issues in fast-running tests. + /// + public override DateTimeOffset TruncateToSystemKeyPrecision(DateTimeOffset dateTimeOffset) + { + return dateTimeOffset.Truncate(TimeSpan.FromSeconds(1)); + } + + /// + /// Uses second precision instead of minute precision. + /// This avoids duplicate key issues in fast-running tests. + /// + public override DateTimeOffset TruncateToIntermediateKeyPrecision(DateTimeOffset dateTimeOffset) + { + return dateTimeOffset.Truncate(TimeSpan.FromSeconds(1)); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.slnx b/csharp/AppEncryption/AppEncryption.slnx index 7d2054458..bd9c79907 100644 --- a/csharp/AppEncryption/AppEncryption.slnx +++ b/csharp/AppEncryption/AppEncryption.slnx @@ -3,5 +3,6 @@ + diff --git a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastore.cs b/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastore.cs deleted file mode 100644 index fe3665bfb..000000000 --- a/csharp/AppEncryption/AppEncryption/Metastore/InMemoryKeyMetastore.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Data; -using System.Linq; -using System.Threading.Tasks; - -namespace GoDaddy.Asherah.AppEncryption.Metastore -{ - /// - /// Provides a volatile implementation of for key records using a - /// . NOTE: This should NEVER be used in a production environment. - /// - public class InMemoryKeyMetastore : IKeyMetastore, IDisposable - { - private readonly DataTable _dataTable; - - /// - /// Initializes a new instance of the class, with 3 columns. - /// - /// keyId | created | keyRecord - /// ----- | ------- | --------- - /// | | - /// | | - /// - /// Uses 'keyId' and 'created' as the primary key. - /// - public InMemoryKeyMetastore() - { - _dataTable = new DataTable(); - _dataTable.Columns.Add("keyId", typeof(string)); - _dataTable.Columns.Add("created", typeof(DateTimeOffset)); - _dataTable.Columns.Add("keyRecord", typeof(KeyRecord)); - _dataTable.PrimaryKey = new[] { _dataTable.Columns["keyId"], _dataTable.Columns["created"] }; - } - - /// - public Task<(bool found, IKeyRecord keyRecord)> TryLoadAsync(string keyId, DateTimeOffset created) - { - lock (_dataTable) - { - var dataRows = _dataTable.Rows.Cast() - .Where(row => row["keyId"].Equals(keyId) - && row["created"].Equals(created)) - .ToList(); - if (dataRows.Count == 0) - { - return Task.FromResult((false, (IKeyRecord)null)); - } - - var keyRecord = (IKeyRecord)dataRows.Single()["keyRecord"]; - return Task.FromResult((true, keyRecord)); - } - } - - /// - public Task<(bool found, IKeyRecord keyRecord)> TryLoadLatestAsync(string keyId) - { - lock (_dataTable) - { - var dataRows = _dataTable.Rows.Cast() - .Where(row => row["keyId"].Equals(keyId)) - .OrderBy(row => row["created"]) - .ToList(); - - // Need to check if empty as Last will throw an exception instead of returning null - if (dataRows.Count == 0) - { - return Task.FromResult((false, (IKeyRecord)null)); - } - - var keyRecord = (IKeyRecord)dataRows.Last()["keyRecord"]; - return Task.FromResult((true, keyRecord)); - } - } - - /// - public Task StoreAsync(string keyId, DateTimeOffset created, IKeyRecord keyRecord) - { - lock (_dataTable) - { - var dataRows = _dataTable.Rows.Cast() - .Where(row => row["keyId"].Equals(keyId) - && row["created"].Equals(created)) - .ToList(); - if (dataRows.Count > 0) - { - return Task.FromResult(false); - } - - _dataTable.Rows.Add(keyId, created, keyRecord); - return Task.FromResult(true); - } - } - - /// - public string GetKeySuffix() - { - return string.Empty; - } - - /// - /// Disposes of the managed resources. - /// - public void Dispose() - { - Dispose(true); - } - - /// - /// Disposes of the managed resources. - /// - /// True if called from Dispose, false if called from finalizer. - protected virtual void Dispose(bool disposing) - { - if (!disposing) - { - return; - } - - lock (_dataTable) - { - _dataTable?.Dispose(); - } - } - } -} From 953c72632c84a64ecabf07303959dcd50fa86883 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Tue, 20 Jan 2026 15:36:37 -0700 Subject: [PATCH 26/31] Add Core namespace with modernized session and partition abstractions Introduces a new Core namespace with cleaner abstractions: - ISessionPartition/SessionPartition: partition with unified suffix handling - IEncryptionSession/EncryptionSession/CachedEncryptionSession: simplified session API - ISessionFactoryBuilder/SessionFactoryBuilder/SessionFactory: builder pattern for sessions - IEnvelopeCryptoContext/SessionCryptoContext: encapsulates crypto dependencies Refactors Partition to delegate to SessionPartition and simplifies SuffixedPartition. Updates EnvelopeEncryption to use ISessionPartition and IEnvelopeCryptoContext. --- .../AppEncryption.IntegrationTests.csproj | 4 +- .../AppEncryption.PlugIns.Aws.csproj | 6 +- .../AppEncryption.Tests.csproj | 4 +- .../Core/SessionPartitionTests.cs | 165 ++++++++++++++++++ .../Envelope/EnvelopeEncryptionTests.cs | 8 +- .../AppEncryption/AppEncryption.csproj | 12 +- .../Core/CachedEncryptionSession.cs | 63 +++++++ .../AppEncryption/Core/EncryptionSession.cs | 68 ++++++++ .../AppEncryption/Core/IEncryptionSession.cs | 39 +++++ .../Core/ISessionFactoryBuilder.cs | 49 ++++++ .../AppEncryption/Core/ISessionPartition.cs | 45 +++++ .../Core/SessionCryptoContext.cs | 49 ++++++ .../AppEncryption/Core/SessionFactory.cs | 133 ++++++++++++++ .../Core/SessionFactoryBuilder.cs | 104 +++++++++++ .../AppEncryption/Core/SessionPartition.cs | 94 ++++++++++ .../Envelope/EnvelopeEncryption.cs | 62 +++---- .../Envelope/IEnvelopeCryptoContext.cs | 33 ++++ .../AppEncryption/AppEncryption/Partition.cs | 60 +++++-- .../AppEncryption/SuffixedPartition.cs | 29 +-- csharp/AppEncryption/Crypto/Crypto.csproj | 4 +- 20 files changed, 944 insertions(+), 87 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionPartitionTests.cs create mode 100644 csharp/AppEncryption/AppEncryption/Core/CachedEncryptionSession.cs create mode 100644 csharp/AppEncryption/AppEncryption/Core/EncryptionSession.cs create mode 100644 csharp/AppEncryption/AppEncryption/Core/IEncryptionSession.cs create mode 100644 csharp/AppEncryption/AppEncryption/Core/ISessionFactoryBuilder.cs create mode 100644 csharp/AppEncryption/AppEncryption/Core/ISessionPartition.cs create mode 100644 csharp/AppEncryption/AppEncryption/Core/SessionCryptoContext.cs create mode 100644 csharp/AppEncryption/AppEncryption/Core/SessionFactory.cs create mode 100644 csharp/AppEncryption/AppEncryption/Core/SessionFactoryBuilder.cs create mode 100644 csharp/AppEncryption/AppEncryption/Core/SessionPartition.cs create mode 100644 csharp/AppEncryption/AppEncryption/Envelope/IEnvelopeCryptoContext.cs diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index 7a46703c6..95b44312b 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -10,14 +10,14 @@ $(NoWarn);CS0618;CA1816;CA1873 - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj index be34fd1d7..aca517eba 100644 --- a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj @@ -24,9 +24,9 @@ - - - + + + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index 0a77d6b61..229d48d11 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -10,13 +10,13 @@ $(NoWarn);CS0618;CA1816 - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionPartitionTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionPartitionTests.cs new file mode 100644 index 000000000..75fcf7384 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionPartitionTests.cs @@ -0,0 +1,165 @@ +using GoDaddy.Asherah.AppEncryption.Core; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Core +{ + public class SessionPartitionTests + { + private const string TestPartitionId = "test_partition_id"; + private const string TestServiceId = "test_service_id"; + private const string TestProductId = "test_product_id"; + private const string TestSuffix = "test_suffix"; + + [Fact] + public void Constructor_WithoutSuffix_CreatesPartition() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId); + + Assert.NotNull(partition); + } + + [Fact] + public void Constructor_WithSuffix_CreatesPartition() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, TestSuffix); + + Assert.NotNull(partition); + } + + [Theory] + [InlineData(null)] + [InlineData(TestSuffix)] + public void PartitionId_ReturnsCorrectValue(string suffix) + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, suffix); + + Assert.Equal(TestPartitionId, partition.PartitionId); + } + + [Theory] + [InlineData(null)] + [InlineData(TestSuffix)] + public void ServiceId_ReturnsCorrectValue(string suffix) + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, suffix); + + Assert.Equal(TestServiceId, partition.ServiceId); + } + + [Theory] + [InlineData(null)] + [InlineData(TestSuffix)] + public void ProductId_ReturnsCorrectValue(string suffix) + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, suffix); + + Assert.Equal(TestProductId, partition.ProductId); + } + + [Fact] + public void Suffix_WithoutSuffix_ReturnsNull() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId); + + Assert.Null(partition.Suffix); + } + + [Fact] + public void Suffix_WithSuffix_ReturnsCorrectValue() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, TestSuffix); + + Assert.Equal(TestSuffix, partition.Suffix); + } + + [Fact] + public void SystemKeyId_WithoutSuffix_ReturnsCorrectFormat() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId); + + const string expected = "_SK_" + TestServiceId + "_" + TestProductId; + Assert.Equal(expected, partition.SystemKeyId); + } + + [Fact] + public void SystemKeyId_WithSuffix_ReturnsCorrectFormat() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, TestSuffix); + + const string expected = "_SK_" + TestServiceId + "_" + TestProductId + "_" + TestSuffix; + Assert.Equal(expected, partition.SystemKeyId); + } + + [Fact] + public void IntermediateKeyId_WithoutSuffix_ReturnsCorrectFormat() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId); + + const string expected = "_IK_" + TestPartitionId + "_" + TestServiceId + "_" + TestProductId; + Assert.Equal(expected, partition.IntermediateKeyId); + } + + [Fact] + public void IntermediateKeyId_WithSuffix_ReturnsCorrectFormat() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, TestSuffix); + + const string expected = "_IK_" + TestPartitionId + "_" + TestServiceId + "_" + TestProductId + "_" + TestSuffix; + Assert.Equal(expected, partition.IntermediateKeyId); + } + + [Fact] + public void IsValidIntermediateKeyId_WithoutSuffix_MatchesExactKeyId() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId); + + const string validKeyId = "_IK_" + TestPartitionId + "_" + TestServiceId + "_" + TestProductId; + Assert.True(partition.IsValidIntermediateKeyId(validKeyId)); + } + + [Fact] + public void IsValidIntermediateKeyId_WithoutSuffix_RejectsInvalidKeyId() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId); + + const string invalidKeyId = "_IK_other_partition_" + TestServiceId + "_" + TestProductId; + Assert.False(partition.IsValidIntermediateKeyId(invalidKeyId)); + } + + [Fact] + public void IsValidIntermediateKeyId_WithSuffix_MatchesExactSuffixedKeyId() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, TestSuffix); + + const string suffixedKeyId = "_IK_" + TestPartitionId + "_" + TestServiceId + "_" + TestProductId + "_" + TestSuffix; + Assert.True(partition.IsValidIntermediateKeyId(suffixedKeyId)); + } + + [Fact] + public void IsValidIntermediateKeyId_WithSuffix_MatchesBaseKeyId() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, TestSuffix); + + const string baseKeyId = "_IK_" + TestPartitionId + "_" + TestServiceId + "_" + TestProductId; + Assert.True(partition.IsValidIntermediateKeyId(baseKeyId)); + } + + [Fact] + public void IsValidIntermediateKeyId_WithSuffix_MatchesOtherSuffixedKeyId() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, TestSuffix); + + const string otherSuffixedKeyId = "_IK_" + TestPartitionId + "_" + TestServiceId + "_" + TestProductId + "_other_suffix"; + Assert.True(partition.IsValidIntermediateKeyId(otherSuffixedKeyId)); + } + + [Fact] + public void IsValidIntermediateKeyId_WithSuffix_RejectsInvalidKeyId() + { + var partition = new SessionPartition(TestPartitionId, TestServiceId, TestProductId, TestSuffix); + + const string invalidKeyId = "_IK_other_partition_" + TestServiceId + "_" + TestProductId; + Assert.False(partition.IsValidIntermediateKeyId(invalidKeyId)); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs index c0074cfc8..464de39da 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Core; using GoDaddy.Asherah.AppEncryption.Envelope; using GoDaddy.Asherah.AppEncryption.Exceptions; using GoDaddy.Asherah.AppEncryption.Kms; @@ -46,16 +47,13 @@ private EnvelopeEncryption NewEnvelopeEncryption( .Build(); var systemKeyCache = new SecureCryptoKeyDictionary(cryptoPolicy.GetRevokeCheckPeriodMillis()); - var intermediateKeyCache = new SecureCryptoKeyDictionary(cryptoPolicy.GetRevokeCheckPeriodMillis()); + var cryptoContext = new SessionCryptoContext(crypto, cryptoPolicy, systemKeyCache); return new EnvelopeEncryption( partition, metastore, keyManagementService, - crypto, - cryptoPolicy, - systemKeyCache, - intermediateKeyCache, + cryptoContext, logger); } diff --git a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj index f270d158a..6441c5e50 100644 --- a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj +++ b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj @@ -24,15 +24,15 @@ - - + + - - + + - - + + diff --git a/csharp/AppEncryption/AppEncryption/Core/CachedEncryptionSession.cs b/csharp/AppEncryption/AppEncryption/Core/CachedEncryptionSession.cs new file mode 100644 index 000000000..d2e5a8cea --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Core/CachedEncryptionSession.cs @@ -0,0 +1,63 @@ +using System.Threading.Tasks; + +namespace GoDaddy.Asherah.AppEncryption.Core +{ + /// + /// A wrapper around that prevents consumers from + /// disposing the underlying session. Used when session caching is enabled. + /// + internal class CachedEncryptionSession : IEncryptionSession + { + private readonly EncryptionSession _session; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying encryption session to delegate to. + internal CachedEncryptionSession(EncryptionSession session) + { + _session = session; + } + + /// + public byte[] Encrypt(byte[] payload) + { + return _session.Encrypt(payload); + } + + /// + public Task EncryptAsync(byte[] payload) + { + return _session.EncryptAsync(payload); + } + + /// + public byte[] Decrypt(byte[] dataRowRecord) + { + return _session.Decrypt(dataRowRecord); + } + + /// + public Task DecryptAsync(byte[] dataRowRecord) + { + return _session.DecryptAsync(dataRowRecord); + } + + /// + /// No-op. Cached sessions are managed by the and + /// will be disposed when the factory is disposed. + /// + public void Dispose() + { + // No-op: The underlying session is owned by the SessionFactory + } + + /// + /// Disposes the underlying session. Called by during disposal. + /// + internal void DisposeUnderlying() + { + _session.Dispose(); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Core/EncryptionSession.cs b/csharp/AppEncryption/AppEncryption/Core/EncryptionSession.cs new file mode 100644 index 000000000..a3f1978dd --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Core/EncryptionSession.cs @@ -0,0 +1,68 @@ +using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Envelope; + +namespace GoDaddy.Asherah.AppEncryption.Core +{ + /// + /// Provides encryption and decryption operations for a specific partition. + /// + internal class EncryptionSession : IEncryptionSession + { + private readonly IEnvelopeEncryption _envelopeEncryption; + + /// + /// Initializes a new instance of the class. + /// + /// The envelope encryption implementation to delegate to. + internal EncryptionSession(IEnvelopeEncryption envelopeEncryption) + { + _envelopeEncryption = envelopeEncryption; + } + + /// + /// Encrypts a payload and returns the encrypted data row record. + /// + /// The payload to encrypt. + /// The encrypted data row record. + public byte[] Encrypt(byte[] payload) + { + return _envelopeEncryption.EncryptPayload(payload); + } + + /// + /// Encrypts a payload and returns the encrypted data row record asynchronously. + /// + /// The payload to encrypt. + /// The encrypted data row record. + public Task EncryptAsync(byte[] payload) + { + return _envelopeEncryption.EncryptPayloadAsync(payload); + } + + /// + /// Decrypts a data row record and returns the original payload. + /// + /// The encrypted data row record. + /// The decrypted payload. + public byte[] Decrypt(byte[] dataRowRecord) + { + return _envelopeEncryption.DecryptDataRowRecord(dataRowRecord); + } + + /// + /// Decrypts a data row record and returns the original payload asynchronously. + /// + /// The encrypted data row record. + /// The decrypted payload. + public Task DecryptAsync(byte[] dataRowRecord) + { + return _envelopeEncryption.DecryptDataRowRecordAsync(dataRowRecord); + } + + /// + public void Dispose() + { + _envelopeEncryption.Dispose(); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Core/IEncryptionSession.cs b/csharp/AppEncryption/AppEncryption/Core/IEncryptionSession.cs new file mode 100644 index 000000000..f45ca32ad --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Core/IEncryptionSession.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; + +namespace GoDaddy.Asherah.AppEncryption.Core +{ + /// + /// Provides encryption and decryption operations for a specific partition. + /// + public interface IEncryptionSession : IDisposable + { + /// + /// Encrypts a payload and returns the encrypted data row record. + /// + /// The payload to encrypt. + /// The encrypted data row record. + byte[] Encrypt(byte[] payload); + + /// + /// Encrypts a payload and returns the encrypted data row record asynchronously. + /// + /// The payload to encrypt. + /// The encrypted data row record. + Task EncryptAsync(byte[] payload); + + /// + /// Decrypts a data row record and returns the original payload. + /// + /// The encrypted data row record. + /// The decrypted payload. + byte[] Decrypt(byte[] dataRowRecord); + + /// + /// Decrypts a data row record and returns the original payload asynchronously. + /// + /// The encrypted data row record. + /// The decrypted payload. + Task DecryptAsync(byte[] dataRowRecord); + } +} diff --git a/csharp/AppEncryption/AppEncryption/Core/ISessionFactoryBuilder.cs b/csharp/AppEncryption/AppEncryption/Core/ISessionFactoryBuilder.cs new file mode 100644 index 000000000..9b068981d --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Core/ISessionFactoryBuilder.cs @@ -0,0 +1,49 @@ +using GoDaddy.Asherah.AppEncryption.Kms; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.Crypto; +using Microsoft.Extensions.Logging; + +namespace GoDaddy.Asherah.AppEncryption.Core +{ + /// + /// Builder interface for creating instances. + /// + public interface ISessionFactoryBuilder + { + /// + /// Sets the key metastore for the session factory. + /// + /// The implementation to use for storing keys. + /// This builder instance for method chaining. + ISessionFactoryBuilder WithKeyMetastore(IKeyMetastore keyMetastore); + + /// + /// Sets the crypto policy for the session factory. + /// + /// The implementation that dictates + /// the various behaviors of Asherah. + /// This builder instance for method chaining. + ISessionFactoryBuilder WithCryptoPolicy(CryptoPolicy cryptoPolicy); + + /// + /// Sets the key management service for the session factory. + /// + /// The implementation that generates + /// the top level master key and encrypts the system keys using the master key. + /// This builder instance for method chaining. + ISessionFactoryBuilder WithKeyManagementService(IKeyManagementService keyManagementService); + + /// + /// Sets the logger for the session factory. + /// + /// The logger implementation to use. + /// This builder instance for method chaining. + ISessionFactoryBuilder WithLogger(ILogger logger); + + /// + /// Builds the finalized session factory with the configured parameters. + /// + /// The fully instantiated . + SessionFactory Build(); + } +} diff --git a/csharp/AppEncryption/AppEncryption/Core/ISessionPartition.cs b/csharp/AppEncryption/AppEncryption/Core/ISessionPartition.cs new file mode 100644 index 000000000..adf9840c0 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Core/ISessionPartition.cs @@ -0,0 +1,45 @@ +namespace GoDaddy.Asherah.AppEncryption.Core +{ + /// + /// Represents a partition for encryption operations. + /// + public interface ISessionPartition + { + /// + /// Gets the partition id. + /// + string PartitionId { get; } + + /// + /// Gets the service id. + /// + string ServiceId { get; } + + /// + /// Gets the product id. + /// + string ProductId { get; } + + /// + /// Gets the optional suffix appended to key ids. + /// + string Suffix { get; } + + /// + /// Gets the system key id. + /// + string SystemKeyId { get; } + + /// + /// Gets the intermediate key id. + /// + string IntermediateKeyId { get; } + + /// + /// Validates whether the given key id matches this partition's intermediate key id. + /// + /// The key id to validate. + /// True if the key id matches this partition's intermediate key id. + bool IsValidIntermediateKeyId(string keyId); + } +} diff --git a/csharp/AppEncryption/AppEncryption/Core/SessionCryptoContext.cs b/csharp/AppEncryption/AppEncryption/Core/SessionCryptoContext.cs new file mode 100644 index 000000000..3b9107128 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Core/SessionCryptoContext.cs @@ -0,0 +1,49 @@ +using System; +using GoDaddy.Asherah.AppEncryption.Envelope; +using GoDaddy.Asherah.Crypto; +using GoDaddy.Asherah.Crypto.Envelope; +using GoDaddy.Asherah.Crypto.Keys; + +namespace GoDaddy.Asherah.AppEncryption.Core +{ + /// + /// Holds the cryptographic context used for a session's envelope encryption operations. + /// + internal class SessionCryptoContext : IEnvelopeCryptoContext + { + /// + public AeadEnvelopeCrypto Crypto { get; } + + /// + public CryptoPolicy Policy { get; } + + /// + public SecureCryptoKeyDictionary SystemKeyCache { get; } + + /// + public SecureCryptoKeyDictionary IntermediateKeyCache { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The envelope crypto implementation. + /// The crypto policy. + /// The shared system key cache. + public SessionCryptoContext( + AeadEnvelopeCrypto crypto, + CryptoPolicy policy, + SecureCryptoKeyDictionary systemKeyCache) + { + Crypto = crypto; + Policy = policy; + SystemKeyCache = systemKeyCache; + IntermediateKeyCache = new SecureCryptoKeyDictionary(policy.GetRevokeCheckPeriodMillis()); + } + + /// + public void Dispose() + { + IntermediateKeyCache?.Dispose(); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Core/SessionFactory.cs b/csharp/AppEncryption/AppEncryption/Core/SessionFactory.cs new file mode 100644 index 000000000..db5ead0ac --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Core/SessionFactory.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Concurrent; +using GoDaddy.Asherah.AppEncryption.Envelope; +using GoDaddy.Asherah.AppEncryption.Kms; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.Crypto; +using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; +using GoDaddy.Asherah.Crypto.Keys; +using Microsoft.Extensions.Logging; + +namespace GoDaddy.Asherah.AppEncryption.Core +{ + /// + /// A session factory is required to generate cryptographic sessions. + /// This is the modernized implementation that supports only byte[] sessions. + /// + public class SessionFactory : IDisposable + { + private readonly string _productId; + private readonly string _serviceId; + private readonly IKeyMetastore _keyMetastore; + private readonly CryptoPolicy _cryptoPolicy; + private readonly IKeyManagementService _keyManagementService; + private readonly ILogger _logger; + private readonly BouncyAes256GcmCrypto _crypto; + private readonly SecureCryptoKeyDictionary _systemKeyCache; + private readonly bool _canCacheSessions; + private readonly ConcurrentDictionary _sessionCache; + + /// + /// Initializes a new instance of the class. + /// + /// A unique identifier for a product. + /// A unique identifier for a service. + /// The key metastore for storing keys. + /// The crypto policy that dictates behaviors. + /// The key management service for master key operations. + /// Logger for diagnostics. + internal SessionFactory( + string productId, + string serviceId, + IKeyMetastore keyMetastore, + CryptoPolicy cryptoPolicy, + IKeyManagementService keyManagementService, + ILogger logger) + { + _productId = productId; + _serviceId = serviceId; + _keyMetastore = keyMetastore; + _cryptoPolicy = cryptoPolicy; + _keyManagementService = keyManagementService; + _logger = logger; + _crypto = new BouncyAes256GcmCrypto(); + _systemKeyCache = new SecureCryptoKeyDictionary(cryptoPolicy.GetRevokeCheckPeriodMillis()); + _canCacheSessions = cryptoPolicy.CanCacheSessions(); + _sessionCache = new ConcurrentDictionary(); + } + + /// + /// Creates a new builder for constructing a . + /// + /// A unique identifier for a product. + /// A unique identifier for a service. + /// A new instance. + public static ISessionFactoryBuilder NewBuilder(string productId, string serviceId) + { + return new SessionFactoryBuilder(productId, serviceId); + } + + /// + public void Dispose() + { + try + { + _systemKeyCache?.Dispose(); + } + catch (Exception e) + { + _logger?.LogError(e, "Unexpected exception during system key cache dispose"); + } + + foreach (var session in _sessionCache.Values) + { + try + { + session.DisposeUnderlying(); + } + catch (Exception e) + { + _logger?.LogError(e, "Unexpected exception during session dispose"); + } + } + + _sessionCache.Clear(); + } + + /// + /// Gets an encryption session for the specified partition. + /// + /// A unique identifier for a session. + /// An for the specified partition. + public IEncryptionSession GetSession(string partitionId) + { + if (_canCacheSessions) + { + return _sessionCache.GetOrAdd(partitionId, CreateCachedSession); + } + + return CreateNewSession(partitionId); + } + + private CachedEncryptionSession CreateCachedSession(string partitionId) + { + return new CachedEncryptionSession(CreateNewSession(partitionId)); + } + + private EncryptionSession CreateNewSession(string partitionId) + { + var suffix = _keyMetastore.GetKeySuffix(); + var partition = new SessionPartition(partitionId, _serviceId, _productId, suffix); + var cryptoContext = new SessionCryptoContext(_crypto, _cryptoPolicy, _systemKeyCache); + + var envelopeEncryption = new EnvelopeEncryption( + partition, + _keyMetastore, + _keyManagementService, + cryptoContext, + _logger); + + return new EncryptionSession(envelopeEncryption); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Core/SessionFactoryBuilder.cs b/csharp/AppEncryption/AppEncryption/Core/SessionFactoryBuilder.cs new file mode 100644 index 000000000..d6255e3dd --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Core/SessionFactoryBuilder.cs @@ -0,0 +1,104 @@ +using System; +using GoDaddy.Asherah.AppEncryption.Kms; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.Crypto; +using Microsoft.Extensions.Logging; + +namespace GoDaddy.Asherah.AppEncryption.Core +{ + /// + /// Builder for creating instances. + /// + internal class SessionFactoryBuilder : ISessionFactoryBuilder + { + private readonly string _productId; + private readonly string _serviceId; + + private IKeyMetastore _keyMetastore; + private CryptoPolicy _cryptoPolicy; + private IKeyManagementService _keyManagementService; + private ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// A unique identifier for a product. + /// A unique identifier for a service. + /// Thrown when productId or serviceId is null or empty. + public SessionFactoryBuilder(string productId, string serviceId) + { + if (string.IsNullOrEmpty(productId)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(productId)); + } + + if (string.IsNullOrEmpty(serviceId)) + { + throw new ArgumentException("Value cannot be null or empty.", nameof(serviceId)); + } + + _productId = productId; + _serviceId = serviceId; + } + + /// + public ISessionFactoryBuilder WithKeyMetastore(IKeyMetastore keyMetastore) + { + _keyMetastore = keyMetastore; + return this; + } + + /// + public ISessionFactoryBuilder WithCryptoPolicy(CryptoPolicy cryptoPolicy) + { + _cryptoPolicy = cryptoPolicy; + return this; + } + + /// + public ISessionFactoryBuilder WithKeyManagementService(IKeyManagementService keyManagementService) + { + _keyManagementService = keyManagementService; + return this; + } + + /// + public ISessionFactoryBuilder WithLogger(ILogger logger) + { + _logger = logger; + return this; + } + + /// + public SessionFactory Build() + { + if (_keyMetastore == null) + { + throw new InvalidOperationException("Key metastore must be set using WithKeyMetastore()"); + } + + if (_cryptoPolicy == null) + { + throw new InvalidOperationException("Crypto policy must be set using WithCryptoPolicy()"); + } + + if (_keyManagementService == null) + { + throw new InvalidOperationException("Key management service must be set using WithKeyManagementService()"); + } + + if (_logger == null) + { + throw new InvalidOperationException("Logger must be set using WithLogger()"); + } + + return new SessionFactory( + _productId, + _serviceId, + _keyMetastore, + _cryptoPolicy, + _keyManagementService, + _logger); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Core/SessionPartition.cs b/csharp/AppEncryption/AppEncryption/Core/SessionPartition.cs new file mode 100644 index 000000000..28d09aabf --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Core/SessionPartition.cs @@ -0,0 +1,94 @@ +using System; + +namespace GoDaddy.Asherah.AppEncryption.Core +{ + /// + /// Represents a partition for encryption operations, generating system key and intermediate key ids. + /// + public class SessionPartition : ISessionPartition + { + private const string SystemKeyPrefix = "_SK_"; + private const string IntermediateKeyPrefix = "_IK_"; + private const string Separator = "_"; + + private readonly string _baseIntermediateKeyId; + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for this partition. + /// The unique identifier for the service. + /// The unique identifier for the product. + /// Optional suffix appended to key ids (e.g., region identifier for DynamoDB Global Tables). + public SessionPartition(string partitionId, string serviceId, string productId, string suffix = null) + { + PartitionId = partitionId; + ServiceId = serviceId; + ProductId = productId; + Suffix = suffix; + + var baseKeyPart = serviceId + Separator + productId; + var baseSystemKeyId = SystemKeyPrefix + baseKeyPart; + _baseIntermediateKeyId = IntermediateKeyPrefix + partitionId + Separator + baseKeyPart; + + if (!string.IsNullOrEmpty(suffix)) + { + SystemKeyId = baseSystemKeyId + Separator + suffix; + IntermediateKeyId = _baseIntermediateKeyId + Separator + suffix; + } + else + { + SystemKeyId = baseSystemKeyId; + IntermediateKeyId = _baseIntermediateKeyId; + } + } + + /// + /// Gets the partition id. + /// + public string PartitionId { get; } + + /// + /// Gets the service id. + /// + public string ServiceId { get; } + + /// + /// Gets the product id. + /// + public string ProductId { get; } + + /// + /// Gets the optional suffix appended to key ids. + /// + public string Suffix { get; } + + /// + /// Gets the system key id. + /// + public string SystemKeyId { get; } + + /// + /// Gets the intermediate key id. + /// + public string IntermediateKeyId { get; } + + /// + public bool IsValidIntermediateKeyId(string keyId) + { + // Exact match with full key id (including suffix if present) + if (string.Equals(keyId, IntermediateKeyId, StringComparison.Ordinal)) + { + return true; + } + + // If we have a suffix, also allow matching the base key id (for backwards compatibility) + if (!string.IsNullOrEmpty(Suffix)) + { + return keyId.StartsWith(_baseIntermediateKeyId, StringComparison.Ordinal); + } + + return false; + } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs index e1a62725d..e594d1613 100644 --- a/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs +++ b/csharp/AppEncryption/AppEncryption/Envelope/EnvelopeEncryption.cs @@ -2,14 +2,15 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Core; using GoDaddy.Asherah.AppEncryption.Exceptions; using GoDaddy.Asherah.AppEncryption.Metastore; using GoDaddy.Asherah.AppEncryption.Serialization; using GoDaddy.Asherah.AppEncryption.Kms; -using GoDaddy.Asherah.Crypto.Envelope; -using GoDaddy.Asherah.Crypto.Keys; using GoDaddy.Asherah.Crypto; +using GoDaddy.Asherah.Crypto.Envelope; using GoDaddy.Asherah.Crypto.Exceptions; +using GoDaddy.Asherah.Crypto.Keys; using Microsoft.Extensions.Logging; using MetastoreKeyMeta = GoDaddy.Asherah.AppEncryption.Metastore.KeyMeta; @@ -43,44 +44,44 @@ internal sealed class EnvelopeEncryption : IEnvelopeEncryption } }; - private readonly Partition _partition; - private readonly AeadEnvelopeCrypto _crypto; + private readonly ISessionPartition _partition; private readonly IKeyMetastore _metastore; - private readonly SecureCryptoKeyDictionary _systemKeyCache; - private readonly SecureCryptoKeyDictionary _intermediateKeyCache; - private readonly CryptoPolicy _cryptoPolicy; private readonly IKeyManagementService _keyManagementService; + private readonly IEnvelopeCryptoContext _cryptoContext; private readonly ILogger _logger; + // Cached properties from IEnvelopeCryptoContext to avoid interface dispatch overhead + private readonly AeadEnvelopeCrypto _crypto; + private readonly CryptoPolicy _policy; + private readonly SecureCryptoKeyDictionary _systemKeyCache; + private readonly SecureCryptoKeyDictionary _intermediateKeyCache; + /// /// Initializes a new instance of the class. /// /// The partition for this envelope encryption instance. /// The metastore for storing and retrieving keys. /// Service for key management operations. - /// The crypto implementation for envelope operations. - /// Policy that dictates crypto behaviors. - /// Cache for system keys. - /// Cache for intermediate keys. + /// The crypto context containing crypto, policy, and key caches. /// The logger implementation to use. public EnvelopeEncryption( - Partition partition, + ISessionPartition partition, IKeyMetastore metastore, IKeyManagementService keyManagementService, - AeadEnvelopeCrypto crypto, - CryptoPolicy cryptoPolicy, - SecureCryptoKeyDictionary systemKeyCache, - SecureCryptoKeyDictionary intermediateKeyCache, + IEnvelopeCryptoContext cryptoContext, ILogger logger) { _partition = partition; _metastore = metastore; _keyManagementService = keyManagementService; - _crypto = crypto; - _cryptoPolicy = cryptoPolicy; - _systemKeyCache = systemKeyCache; - _intermediateKeyCache = intermediateKeyCache; + _cryptoContext = cryptoContext; _logger = logger; + + // Cache properties to avoid repeated interface dispatch + _crypto = cryptoContext.Crypto; + _policy = cryptoContext.Policy; + _systemKeyCache = cryptoContext.SystemKeyCache; + _intermediateKeyCache = cryptoContext.IntermediateKeyCache; } /// @@ -107,7 +108,7 @@ public async Task DecryptDataRowRecordAsync(byte[] dataRowRecord) if (!_partition.IsValidIntermediateKeyId(dataRowRecordModel.Key.ParentKeyMeta.KeyId)) { - throw new MetadataMissingException("Could not find parentKeyMeta {IK} for dataRowKey"); + throw new MetadataMissingException($"Intermediate key '{dataRowRecordModel.Key.ParentKeyMeta.KeyId}' does not match partition '{_partition.IntermediateKeyId}'"); } // the Data property is a base64 encoded string containing the encrypted payload @@ -166,7 +167,7 @@ private async Task WithIntermediateKeyForWrite(Func function intermediateKey = await GetLatestOrCreateIntermediateKey(); // Put the key into our cache if allowed - if (_cryptoPolicy.CanCacheIntermediateKeys()) + if (_policy.CanCacheIntermediateKeys()) { try { @@ -197,7 +198,7 @@ private async Task WithSystemKeyForWrite(Func functionWithSy systemKey = await GetLatestOrCreateSystemKey(); // Put the key into our cache if allowed - if (_cryptoPolicy.CanCacheSystemKeys()) + if (_policy.CanCacheSystemKeys()) { try { @@ -256,7 +257,7 @@ private async Task GetLatestOrCreateIntermediateKey() } // Phase 2: Create new intermediate key - var intermediateKeyCreated = _cryptoPolicy.TruncateToIntermediateKeyPrecision(DateTime.UtcNow); + var intermediateKeyCreated = _policy.TruncateToIntermediateKeyPrecision(DateTime.UtcNow); var intermediateKey = _crypto.GenerateKey(intermediateKeyCreated); try @@ -322,7 +323,7 @@ private async Task WithIntermediateKeyForRead( intermediateKey = await GetIntermediateKey(intermediateKeyMeta); // Put the key into our cache if allowed - if (_cryptoPolicy.CanCacheIntermediateKeys()) + if (_policy.CanCacheIntermediateKeys()) { try { @@ -393,7 +394,7 @@ private async Task WithExistingSystemKey( systemKey = await GetSystemKey(systemKeyMeta); // Put the key into our cache if allowed - if (_cryptoPolicy.CanCacheSystemKeys()) + if (_policy.CanCacheSystemKeys()) { try { @@ -467,7 +468,7 @@ private async Task GetLatestOrCreateSystemKey() } // Phase 2: Create new key - var systemKeyCreated = _cryptoPolicy.TruncateToSystemKeyPrecision(DateTimeOffset.UtcNow); + var systemKeyCreated = _policy.TruncateToSystemKeyPrecision(DateTimeOffset.UtcNow); var systemKey = _crypto.GenerateKey(systemKeyCreated); try { @@ -531,7 +532,7 @@ private CryptoKey DecryptKey(IKeyRecord keyRecord, CryptoKey keyEncryptionKey) /// True if the key record is expired or revoked, false otherwise. private bool IsKeyExpiredOrRevoked(IKeyRecord keyRecord) { - return _cryptoPolicy.IsKeyExpired(keyRecord.Created) || (keyRecord.Revoked ?? false); + return _policy.IsKeyExpired(keyRecord.Created) || (keyRecord.Revoked ?? false); } /// @@ -541,7 +542,7 @@ private bool IsKeyExpiredOrRevoked(IKeyRecord keyRecord) /// True if the key is expired or revoked, false otherwise. private bool IsKeyExpiredOrRevoked(CryptoKey cryptoKey) { - return _cryptoPolicy.IsKeyExpired(cryptoKey.GetCreated()) || cryptoKey.IsRevoked(); + return _policy.IsKeyExpired(cryptoKey.GetCreated()) || cryptoKey.IsRevoked(); } /// @@ -571,8 +572,7 @@ public void Dispose() { try { - // only close intermediate key cache since its lifecycle is tied to this "session" - _intermediateKeyCache.Dispose(); + _cryptoContext.Dispose(); } catch (Exception ex) { diff --git a/csharp/AppEncryption/AppEncryption/Envelope/IEnvelopeCryptoContext.cs b/csharp/AppEncryption/AppEncryption/Envelope/IEnvelopeCryptoContext.cs new file mode 100644 index 000000000..ba408c65d --- /dev/null +++ b/csharp/AppEncryption/AppEncryption/Envelope/IEnvelopeCryptoContext.cs @@ -0,0 +1,33 @@ +using System; +using GoDaddy.Asherah.Crypto; +using GoDaddy.Asherah.Crypto.Envelope; +using GoDaddy.Asherah.Crypto.Keys; + +namespace GoDaddy.Asherah.AppEncryption.Envelope +{ + /// + /// Defines the cryptographic context used for envelope encryption operations. + /// + public interface IEnvelopeCryptoContext : IDisposable + { + /// + /// Gets the envelope crypto implementation for encryption/decryption operations. + /// + AeadEnvelopeCrypto Crypto { get; } + + /// + /// Gets the crypto policy that dictates key expiration, caching, and rotation behaviors. + /// + CryptoPolicy Policy { get; } + + /// + /// Gets the shared cache for system keys. + /// + SecureCryptoKeyDictionary SystemKeyCache { get; } + + /// + /// Gets the cache for intermediate keys. + /// + SecureCryptoKeyDictionary IntermediateKeyCache { get; } + } +} diff --git a/csharp/AppEncryption/AppEncryption/Partition.cs b/csharp/AppEncryption/AppEncryption/Partition.cs index dddaf79e1..2f693c576 100644 --- a/csharp/AppEncryption/AppEncryption/Partition.cs +++ b/csharp/AppEncryption/AppEncryption/Partition.cs @@ -1,4 +1,4 @@ -using System; +using GoDaddy.Asherah.AppEncryption.Core; namespace GoDaddy.Asherah.AppEncryption { @@ -8,34 +8,66 @@ namespace GoDaddy.Asherah.AppEncryption /// should have its own session. /// A payload encrypted using some partition id, cannot be decrypted using a different one. /// - public abstract class Partition + public abstract class Partition : ISessionPartition { + private readonly SessionPartition _sessionPartition; + + /// + /// Initializes a new instance of the class. + /// + /// A unique identifier for this partition. + /// A unique identifier for a service. + /// A unique identifier for a product. protected Partition(string partitionId, string serviceId, string productId) + : this(partitionId, serviceId, productId, null) { - PartitionId = partitionId; - ServiceId = serviceId; - ProductId = productId; } /// - /// Gets get the system key id. + /// Initializes a new instance of the class with an optional suffix. /// - public virtual string SystemKeyId => "_SK_" + ServiceId + "_" + ProductId; + /// A unique identifier for this partition. + /// A unique identifier for a service. + /// A unique identifier for a product. + /// Optional suffix appended to key ids. + protected Partition(string partitionId, string serviceId, string productId, string suffix) + { + _sessionPartition = new SessionPartition(partitionId, serviceId, productId, suffix); + } /// - /// Gets get the intermediate key id. + /// Gets the system key id. /// - public virtual string IntermediateKeyId => "_IK_" + PartitionId + "_" + ServiceId + "_" + ProductId; + public string SystemKeyId => _sessionPartition.SystemKeyId; - internal string PartitionId { get; } + /// + /// Gets the intermediate key id. + /// + public string IntermediateKeyId => _sessionPartition.IntermediateKeyId; - internal string ServiceId { get; } + /// + /// Gets the partition id. + /// + public string PartitionId => _sessionPartition.PartitionId; - internal string ProductId { get; } + /// + /// Gets the service id. + /// + public string ServiceId => _sessionPartition.ServiceId; + + /// + /// Gets the product id. + /// + public string ProductId => _sessionPartition.ProductId; + + /// + /// Gets the optional suffix appended to key ids. Returns null for base partitions. + /// + public string Suffix => _sessionPartition.Suffix; - public virtual bool IsValidIntermediateKeyId(string keyId) + public bool IsValidIntermediateKeyId(string keyId) { - return keyId.Equals(IntermediateKeyId, StringComparison.Ordinal); + return _sessionPartition.IsValidIntermediateKeyId(keyId); } } } diff --git a/csharp/AppEncryption/AppEncryption/SuffixedPartition.cs b/csharp/AppEncryption/AppEncryption/SuffixedPartition.cs index a57ea878c..8e497c8ac 100644 --- a/csharp/AppEncryption/AppEncryption/SuffixedPartition.cs +++ b/csharp/AppEncryption/AppEncryption/SuffixedPartition.cs @@ -1,17 +1,14 @@ -using System; - namespace GoDaddy.Asherah.AppEncryption { + /// + /// An implementation of that is used to support Global Tables in + /// . + /// public class SuffixedPartition : Partition { - private readonly string regionSuffix; - /// - /// Initializes a new instance of the class. An implementation of - /// that is used to support Global Tables in - /// . + /// Initializes a new instance of the class. /// - /// /// A unique identifier for a . /// A unique identifier for a service, used to create a /// object. @@ -19,27 +16,15 @@ public class SuffixedPartition : Partition /// object. /// The suffix to be added to a lookup key when using DynamoDB Global Tables. public SuffixedPartition(string partitionId, string serviceId, string productId, string regionSuffix) - : base(partitionId, serviceId, productId) + : base(partitionId, serviceId, productId, regionSuffix) { - this.regionSuffix = regionSuffix; } - /// - public override string SystemKeyId => base.SystemKeyId + "_" + regionSuffix; - - /// - public override string IntermediateKeyId => base.IntermediateKeyId + "_" + regionSuffix; - /// public override string ToString() { return GetType().Name + "[partitionId=" + PartitionId + - ", serviceId=" + ServiceId + ", productId=" + ProductId + ", regionSuffix=" + regionSuffix + "]"; - } - - public override bool IsValidIntermediateKeyId(string keyId) - { - return keyId.Equals(IntermediateKeyId, StringComparison.Ordinal) || keyId.StartsWith(base.IntermediateKeyId, StringComparison.Ordinal); + ", serviceId=" + ServiceId + ", productId=" + ProductId + ", regionSuffix=" + Suffix + "]"; } } } diff --git a/csharp/AppEncryption/Crypto/Crypto.csproj b/csharp/AppEncryption/Crypto/Crypto.csproj index c8fa0aee2..3cf897fd1 100644 --- a/csharp/AppEncryption/Crypto/Crypto.csproj +++ b/csharp/AppEncryption/Crypto/Crypto.csproj @@ -23,7 +23,7 @@ - - + + From 5aa6d92807f447a5b9c9a21b0d9ee3037795e337 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Wed, 4 Feb 2026 18:10:56 -0700 Subject: [PATCH 27/31] merged main and updated additional packages --- .../AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj | 2 +- .../AppEncryption.Tests/AppEncryption.Tests.csproj | 2 +- csharp/AppEncryption/AppEncryption/AppEncryption.csproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj index 689738e0c..c99fecdfd 100644 --- a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj @@ -25,7 +25,7 @@ - + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index 117d1fcb9..ab208b66d 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -12,7 +12,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj index 82f95f763..fc65c2da7 100644 --- a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj +++ b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj @@ -25,7 +25,7 @@ - + From 37cafc6c8021b113499858f211b750aa6fce1d31 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Fri, 13 Mar 2026 06:47:41 -0700 Subject: [PATCH 28/31] update nuget packages --- .../AppEncryption.IntegrationTests.csproj | 8 ++++---- .../AppEncryption.PlugIns.Aws.csproj | 6 +++--- .../AppEncryption.Tests/AppEncryption.Tests.csproj | 12 ++++++------ .../AppEncryption/AppEncryption/AppEncryption.csproj | 12 ++++++------ csharp/AppEncryption/Crypto/Crypto.csproj | 4 ++-- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index 137b3fa69..878a0a323 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -11,15 +11,15 @@ NU1901;NU1902;NU1903;NU1904 - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj index c99fecdfd..6225158d6 100644 --- a/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Aws/AppEncryption.PlugIns.Aws.csproj @@ -25,9 +25,9 @@ - - - + + + diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj index ab208b66d..9305ee335 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption.Tests.csproj @@ -11,18 +11,18 @@ NU1901;NU1902;NU1903;NU1904 - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + - - + + all diff --git a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj index fc65c2da7..827dfef81 100644 --- a/csharp/AppEncryption/AppEncryption/AppEncryption.csproj +++ b/csharp/AppEncryption/AppEncryption/AppEncryption.csproj @@ -25,15 +25,15 @@ - - + + - - + + - - + + diff --git a/csharp/AppEncryption/Crypto/Crypto.csproj b/csharp/AppEncryption/Crypto/Crypto.csproj index a12c8ee29..62ef07666 100644 --- a/csharp/AppEncryption/Crypto/Crypto.csproj +++ b/csharp/AppEncryption/Crypto/Crypto.csproj @@ -24,7 +24,7 @@ - - + + From 16b6ecf92c2c46503ef17a0894483731f5dfbb0e Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sat, 14 Mar 2026 09:09:23 -0700 Subject: [PATCH 29/31] C# AppEncryption: new metastore, Core session factory, Static KMS in PlugIns.Testing - Add Core SessionFactory/SessionFactoryBuilder and tests - Add CoreSessionFactoryGenerator and Core regression tests - Move StaticKeyManagementService to PlugIns.Testing/Kms, remove DummyKeyManagementService - Update integration tests, ConfigFixture, MetastoreMock for new metastore - SessionFactory and StaticKeyManagementServiceImpl updates - README and test project updates - tartufo: exclude SessionFactoryBuilderTests high-entropy signatures Made-with: Cursor --- .../AppEncryption.IntegrationTests.csproj | 1 + .../ConfigFixture.cs | 7 +- .../AppEncryptionParameterizedTest.cs | 2 +- .../Regression/Core/EncryptionSessionTest.cs | 103 +++++++ .../Regression/MetastoreMock.cs | 2 +- .../Utils/CoreSessionFactoryGenerator.cs | 57 ++++ .../Utils/SessionFactoryGenerator.cs | 4 +- .../AppEncryption.PlugIns.Testing.csproj | 1 + .../Kms/StaticKeyManagementService.cs | 73 +++++ .../Core/CachedEncryptionSessionTests.cs | 34 +++ .../Core/SessionFactoryBuilderTests.cs | 166 +++++++++++ .../AppEncryption/Core/SessionFactoryTests.cs | 267 ++++++++++++++++++ .../Envelope/EnvelopeEncryptionTests.cs | 49 ++-- .../Json/AppJsonEncryptionImplTest.cs | 5 +- .../AppEncryption/SessionFactoryTest.cs | 45 +-- .../Dummy/DummyKeyManagementService.cs | 54 ---- .../Kms/StaticKeyManagementServiceImpl.cs | 1 + .../AppEncryption/SessionFactory.cs | 13 +- csharp/AppEncryption/README.md | 6 +- tartufo.toml | 3 + 20 files changed, 777 insertions(+), 116 deletions(-) create mode 100644 csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Core/EncryptionSessionTest.cs create mode 100644 csharp/AppEncryption/AppEncryption.IntegrationTests/Utils/CoreSessionFactoryGenerator.cs create mode 100644 csharp/AppEncryption/AppEncryption.PlugIns.Testing/Kms/StaticKeyManagementService.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/CachedEncryptionSessionTests.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionFactoryBuilderTests.cs create mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionFactoryTests.cs delete mode 100644 csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/Dummy/DummyKeyManagementService.cs diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index 878a0a323..180e0ab09 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -33,6 +33,7 @@ + diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs index 0c8f8d96e..f37065a3f 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/ConfigFixture.cs @@ -5,6 +5,7 @@ using GoDaddy.Asherah.AppEncryption.IntegrationTests.TestHelpers; using GoDaddy.Asherah.AppEncryption.Kms; using GoDaddy.Asherah.AppEncryption.Persistence; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Kms; using GoDaddy.Asherah.Crypto.Exceptions; using Microsoft.Extensions.Configuration; using MySql.Data.MySqlClient; @@ -48,7 +49,7 @@ public ConfigFixture() Metastore = CreateMetastore(); } - public KeyManagementService KeyManagementService { get; } + public IKeyManagementService KeyManagementService { get; } public IMetastore Metastore { get; } @@ -98,7 +99,7 @@ private IMetastore CreateMetastore() return new InMemoryMetastoreImpl(); } - private KeyManagementService CreateKeyManagementService() + private IKeyManagementService CreateKeyManagementService() { if (KmsType.Equals(KeyManagementAws, StringComparison.OrdinalIgnoreCase)) { @@ -124,7 +125,7 @@ private KeyManagementService CreateKeyManagementService() .Build(); } - return new StaticKeyManagementServiceImpl(KeyManagementStaticMasterKey); + return new StaticKeyManagementService(KeyManagementStaticMasterKey); } } } diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs index 8adaea560..a78206ee0 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/AppEncryptionParameterizedTest.cs @@ -213,7 +213,7 @@ private object[] GenerateMocks(KeyState cacheIK, KeyState metaIK, KeyState cache cacheSK + "CacheSK_" + metaSK + "MetaSK_" + DateTimeUtils.GetCurrentTimeAsUtcIsoDateTimeOffset() + "_" + Random.Next(), DefaultProductId); - KeyManagementService kms = configFixture.KeyManagementService; + IKeyManagementService kms = configFixture.KeyManagementService; CryptoKeyHolder cryptoKeyHolder = CryptoKeyHolder.GenerateIKSK(); diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Core/EncryptionSessionTest.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Core/EncryptionSessionTest.cs new file mode 100644 index 000000000..5001f82f5 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Core/EncryptionSessionTest.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Core; +using GoDaddy.Asherah.AppEncryption.IntegrationTests.Utils; +using Xunit; + +using static GoDaddy.Asherah.AppEncryption.IntegrationTests.TestHelpers.Constants; + +namespace GoDaddy.Asherah.AppEncryption.IntegrationTests.Regression.Core +{ + [Collection("Configuration collection")] + public class EncryptionSessionTest : IDisposable + { + private readonly byte[] payload; + private readonly GoDaddy.Asherah.AppEncryption.Core.SessionFactory sessionFactory; + private readonly string partitionId; + private readonly IEncryptionSession encryptionSession; + + public EncryptionSessionTest(ConfigFixture configFixture) + { + payload = PayloadGenerator.CreateDefaultRandomBytePayload(); + sessionFactory = CoreSessionFactoryGenerator.CreateDefaultSessionFactory( + configFixture.KeyManagementService); + partitionId = DefaultPartitionId + "_" + DateTimeUtils.GetCurrentTimeAsUtcIsoDateTimeOffset(); + encryptionSession = sessionFactory.GetSession(partitionId); + } + + public void Dispose() + { + encryptionSession?.Dispose(); + sessionFactory?.Dispose(); + } + + [Fact] + private void EncryptDecrypt() + { + byte[] dataRowRecord = encryptionSession.Encrypt(payload); + byte[] decryptedPayload = encryptionSession.Decrypt(dataRowRecord); + + Assert.Equal(payload, decryptedPayload); + } + + [Fact] + private void EncryptDecryptSameSessionMultipleRounds() + { + int iterations = 40; + for (int i = 0; i < iterations; i++) + { + byte[] dataRowRecord = encryptionSession.Encrypt(payload); + byte[] decryptedPayload = encryptionSession.Decrypt(dataRowRecord); + + Assert.Equal(payload, decryptedPayload); + } + } + + [Fact] + private void EncryptDecryptWithDifferentSession() + { + byte[] dataRowRecord = encryptionSession.Encrypt(payload); + + using (IEncryptionSession otherSession = sessionFactory.GetSession(partitionId)) + { + byte[] decryptedPayload = otherSession.Decrypt(dataRowRecord); + Assert.Equal(payload, decryptedPayload); + } + } + + [Fact] + private void EncryptDecryptWithDifferentPayloads() + { + byte[] otherPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecord1 = encryptionSession.Encrypt(payload); + byte[] dataRowRecord2 = encryptionSession.Encrypt(otherPayload); + + byte[] decryptedPayload1 = encryptionSession.Decrypt(dataRowRecord1); + byte[] decryptedPayload2 = encryptionSession.Decrypt(dataRowRecord2); + + Assert.Equal(payload, decryptedPayload1); + Assert.Equal(otherPayload, decryptedPayload2); + } + + [Fact] + private async Task EncryptAsyncDecryptAsync() + { + byte[] dataRowRecord = await encryptionSession.EncryptAsync(payload); + byte[] decryptedPayload = await encryptionSession.DecryptAsync(dataRowRecord); + + Assert.Equal(payload, decryptedPayload); + } + + [Fact] + private async Task EncryptAsyncDecryptWithDifferentSession() + { + byte[] dataRowRecord = await encryptionSession.EncryptAsync(payload); + + using (IEncryptionSession otherSession = sessionFactory.GetSession(partitionId)) + { + byte[] decryptedPayload = await otherSession.DecryptAsync(dataRowRecord); + Assert.Equal(payload, decryptedPayload); + } + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/MetastoreMock.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/MetastoreMock.cs index 2d1b653c5..8f5f59bf3 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/MetastoreMock.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/MetastoreMock.cs @@ -16,7 +16,7 @@ public static class MetastoreMock internal static Mock> CreateMetastoreMock( Partition partition, - KeyManagementService kms, + IKeyManagementService kms, KeyState metaIK, KeyState metaSK, CryptoKeyHolder cryptoKeyHolder, diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Utils/CoreSessionFactoryGenerator.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Utils/CoreSessionFactoryGenerator.cs new file mode 100644 index 000000000..2b2539ff5 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Utils/CoreSessionFactoryGenerator.cs @@ -0,0 +1,57 @@ +using GoDaddy.Asherah.AppEncryption.Core; +using GoDaddy.Asherah.AppEncryption.Kms; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Metastore; +using GoDaddy.Asherah.Crypto; +using Microsoft.Extensions.Logging; +using static GoDaddy.Asherah.AppEncryption.IntegrationTests.TestHelpers.Constants; + +namespace GoDaddy.Asherah.AppEncryption.IntegrationTests.Utils +{ + /// + /// Creates instances for integration tests. + /// + public static class CoreSessionFactoryGenerator + { + /// + /// Creates a Core SessionFactory with the given key management service and an in-memory key metastore. + /// + public static GoDaddy.Asherah.AppEncryption.Core.SessionFactory CreateDefaultSessionFactory( + IKeyManagementService keyManagementService) + { + return CreateDefaultSessionFactory(keyManagementService, new InMemoryKeyMetastore()); + } + + /// + /// Creates a Core SessionFactory with the given key management service and key metastore. + /// + public static GoDaddy.Asherah.AppEncryption.Core.SessionFactory CreateDefaultSessionFactory( + IKeyManagementService keyManagementService, + IKeyMetastore keyMetastore) + { + return CreateDefaultSessionFactory( + DefaultProductId, + DefaultServiceId, + keyManagementService, + keyMetastore, + TestLoggerFactory.LoggerFactory.CreateLogger("CoreSessionFactoryGenerator")); + } + + private static GoDaddy.Asherah.AppEncryption.Core.SessionFactory CreateDefaultSessionFactory( + string productId, + string serviceId, + IKeyManagementService keyManagementService, + IKeyMetastore keyMetastore, + ILogger logger) + { + var cryptoPolicy = new NeverExpiredCryptoPolicy(); + return GoDaddy.Asherah.AppEncryption.Core.SessionFactory + .NewBuilder(productId, serviceId) + .WithKeyMetastore(keyMetastore) + .WithCryptoPolicy(cryptoPolicy) + .WithKeyManagementService(keyManagementService) + .WithLogger(logger) + .Build(); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Utils/SessionFactoryGenerator.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Utils/SessionFactoryGenerator.cs index 69fb68198..5ec09970c 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/Utils/SessionFactoryGenerator.cs +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Utils/SessionFactoryGenerator.cs @@ -8,7 +8,7 @@ namespace GoDaddy.Asherah.AppEncryption.IntegrationTests.Utils public static class SessionFactoryGenerator { public static SessionFactory CreateDefaultSessionFactory( - KeyManagementService keyManagementService, IMetastore metastore) + IKeyManagementService keyManagementService, IMetastore metastore) { return CreateDefaultSessionFactory(DefaultProductId, DefaultServiceId, keyManagementService, metastore); } @@ -16,7 +16,7 @@ public static SessionFactory CreateDefaultSessionFactory( private static SessionFactory CreateDefaultSessionFactory( string productId, string serviceId, - KeyManagementService keyManagementService, + IKeyManagementService keyManagementService, IMetastore metastore) { return SessionFactory.NewBuilder(productId, serviceId) diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Testing/AppEncryption.PlugIns.Testing.csproj b/csharp/AppEncryption/AppEncryption.PlugIns.Testing/AppEncryption.PlugIns.Testing.csproj index 2523c78d0..ec319bcc2 100644 --- a/csharp/AppEncryption/AppEncryption.PlugIns.Testing/AppEncryption.PlugIns.Testing.csproj +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Testing/AppEncryption.PlugIns.Testing.csproj @@ -25,5 +25,6 @@ + diff --git a/csharp/AppEncryption/AppEncryption.PlugIns.Testing/Kms/StaticKeyManagementService.cs b/csharp/AppEncryption/AppEncryption.PlugIns.Testing/Kms/StaticKeyManagementService.cs new file mode 100644 index 000000000..76c3418a0 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.PlugIns.Testing/Kms/StaticKeyManagementService.cs @@ -0,0 +1,73 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Kms; +using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; +using GoDaddy.Asherah.Crypto.Keys; +using GoDaddy.Asherah.SecureMemory; + +namespace GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Kms +{ + /// + /// An implementation of that uses a static master key and + /// to encrypt/decrypt keys. + /// NOTE: This should NEVER be used in a production environment. + /// + public sealed class StaticKeyManagementService : IKeyManagementService, IDisposable + { + private readonly SecretCryptoKey _encryptionKey; + private readonly BouncyAes256GcmCrypto _crypto = new BouncyAes256GcmCrypto(); + + /// + /// Initializes a new instance of the class with a + /// randomly generated master key (GUID). Useful for tests that need an ephemeral KMS and + /// do not care about key stability. + /// + public StaticKeyManagementService() + : this(Guid.NewGuid().ToString("N")) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The static master key to use. + public StaticKeyManagementService(string key) + { + byte[] keyBytes = Encoding.UTF8.GetBytes(key); + Secret secretKey = new TransientSecretFactory().CreateSecret(keyBytes); + _encryptionKey = new SecretCryptoKey(secretKey, DateTimeOffset.UtcNow, false); + } + + /// + public byte[] EncryptKey(CryptoKey key) + { + return _crypto.EncryptKey(key, _encryptionKey); + } + + /// + public CryptoKey DecryptKey(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked) + { + return _crypto.DecryptKey(keyCipherText, keyCreated, _encryptionKey, revoked); + } + + /// + public Task EncryptKeyAsync(CryptoKey key) + { + return Task.FromResult(EncryptKey(key)); + } + + /// + public Task DecryptKeyAsync(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked) + { + return Task.FromResult(DecryptKey(keyCipherText, keyCreated, revoked)); + } + + /// + public void Dispose() + { + _encryptionKey?.Dispose(); + _crypto?.Dispose(); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/CachedEncryptionSessionTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/CachedEncryptionSessionTests.cs new file mode 100644 index 000000000..d381719f9 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/CachedEncryptionSessionTests.cs @@ -0,0 +1,34 @@ +using GoDaddy.Asherah.AppEncryption.Core; +using GoDaddy.Asherah.AppEncryption.Envelope; +using Moq; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Core +{ + public class CachedEncryptionSessionTests + { + [Fact] + public void Dispose_DoesNotDisposeUnderlyingEncryptionSession() + { + var envelopeMock = new Mock>(); + var encryptionSession = new EncryptionSession(envelopeMock.Object); + var cached = new CachedEncryptionSession(encryptionSession); + + cached.Dispose(); + + envelopeMock.Verify(e => e.Dispose(), Times.Never); + } + + [Fact] + public void DisposeUnderlying_DisposesUnderlyingEncryptionSession() + { + var envelopeMock = new Mock>(); + var encryptionSession = new EncryptionSession(envelopeMock.Object); + var cached = new CachedEncryptionSession(encryptionSession); + + cached.DisposeUnderlying(); + + envelopeMock.Verify(e => e.Dispose(), Times.Once); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionFactoryBuilderTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionFactoryBuilderTests.cs new file mode 100644 index 000000000..d0f8a3160 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionFactoryBuilderTests.cs @@ -0,0 +1,166 @@ +using System; +using GoDaddy.Asherah.AppEncryption.Core; +using GoDaddy.Asherah.AppEncryption.Kms; +using GoDaddy.Asherah.AppEncryption.Metastore; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Metastore; +using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Kms; +using GoDaddy.Asherah.Crypto; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Core +{ + public class SessionFactoryBuilderTests + { + private const string TestProductId = "test_product_id"; + private const string TestServiceId = "test_service_id"; + + private static ISessionFactoryBuilder NewBuilder() => + GoDaddy.Asherah.AppEncryption.Core.SessionFactory.NewBuilder(TestProductId, TestServiceId); + + private static InMemoryKeyMetastore CreateMetastore() => new InMemoryKeyMetastore(); + + private static BasicExpiringCryptoPolicy CreateCryptoPolicy() => + BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(1) + .WithRevokeCheckMinutes(30) + .WithCanCacheSessions(false) + .Build(); + + private static StaticKeyManagementService CreateKeyManagementService() => new StaticKeyManagementService(); + + private static ILogger CreateLogger() => new LoggerFactoryStub().CreateLogger(nameof(SessionFactoryBuilderTests)); + + [Fact] + public void NewBuilder_WithValidIds_ReturnsBuilder() + { + var builder = GoDaddy.Asherah.AppEncryption.Core.SessionFactory.NewBuilder(TestProductId, TestServiceId); + + Assert.NotNull(builder); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void NewBuilder_WithInvalidProductId_ThrowsArgumentException(string productId) + { + var ex = Assert.Throws(() => + GoDaddy.Asherah.AppEncryption.Core.SessionFactory.NewBuilder(productId, TestServiceId)); + Assert.Equal("productId", ex.ParamName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void NewBuilder_WithInvalidServiceId_ThrowsArgumentException(string serviceId) + { + var ex = Assert.Throws(() => + GoDaddy.Asherah.AppEncryption.Core.SessionFactory.NewBuilder(TestProductId, serviceId)); + Assert.Equal("serviceId", ex.ParamName); + } + + [Fact] + public void Build_WithAllDependencies_ReturnsSessionFactory() + { + using var metastore = CreateMetastore(); + using var keyManagementService = CreateKeyManagementService(); + using var factory = NewBuilder() + .WithKeyMetastore(metastore) + .WithCryptoPolicy(CreateCryptoPolicy()) + .WithKeyManagementService(keyManagementService) + .WithLogger(CreateLogger()) + .Build(); + + Assert.NotNull(factory); + } + + [Fact] + public void Build_WithoutKeyMetastore_ThrowsInvalidOperationException() + { + var ex = Assert.Throws(() => + NewBuilder() + .WithCryptoPolicy(CreateCryptoPolicy()) + .WithKeyManagementService(CreateKeyManagementService()) + .WithLogger(CreateLogger()) + .Build()); + Assert.Contains("Key metastore", ex.Message); + } + + [Fact] + public void Build_WithoutCryptoPolicy_ThrowsInvalidOperationException() + { + using var metastore = new InMemoryKeyMetastore(); + var ex = Assert.Throws(() => + NewBuilder() + .WithKeyMetastore(metastore) + .WithKeyManagementService(CreateKeyManagementService()) + .WithLogger(CreateLogger()) + .Build()); + Assert.Contains("Crypto policy", ex.Message); + } + + [Fact] + public void Build_WithoutKeyManagementService_ThrowsInvalidOperationException() + { + using var metastore = new InMemoryKeyMetastore(); + var ex = Assert.Throws(() => + NewBuilder() + .WithKeyMetastore(metastore) + .WithCryptoPolicy(CreateCryptoPolicy()) + .WithLogger(CreateLogger()) + .Build()); + Assert.Contains("Key management service", ex.Message); + } + + [Fact] + public void Build_WithoutLogger_ThrowsInvalidOperationException() + { + using var metastore = new InMemoryKeyMetastore(); + var ex = Assert.Throws(() => + NewBuilder() + .WithKeyMetastore(metastore) + .WithCryptoPolicy(CreateCryptoPolicy()) + .WithKeyManagementService(CreateKeyManagementService()) + .Build()); + Assert.Contains("Logger", ex.Message); + } + + [Fact] + public void WithKeyMetastore_ReturnsSameBuilderForChaining() + { + using var metastore = new InMemoryKeyMetastore(); + var builder = NewBuilder().WithKeyMetastore(metastore); + + Assert.NotNull(builder); + Assert.Same(builder, builder.WithCryptoPolicy(CreateCryptoPolicy())); + } + + [Fact] + public void WithCryptoPolicy_ReturnsSameBuilderForChaining() + { + var builder = NewBuilder().WithCryptoPolicy(CreateCryptoPolicy()); + + Assert.NotNull(builder); + Assert.Same(builder, builder.WithKeyManagementService(CreateKeyManagementService())); + } + + [Fact] + public void WithKeyManagementService_ReturnsSameBuilderForChaining() + { + var builder = NewBuilder().WithKeyManagementService(CreateKeyManagementService()); + + Assert.NotNull(builder); + Assert.Same(builder, builder.WithLogger(CreateLogger())); + } + + [Fact] + public void WithLogger_ReturnsSameBuilderForChaining() + { + var builder = NewBuilder().WithLogger(CreateLogger()); + + Assert.NotNull(builder); + Assert.Same(builder, builder.WithKeyMetastore(CreateMetastore())); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionFactoryTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionFactoryTests.cs new file mode 100644 index 000000000..f382273f3 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Core/SessionFactoryTests.cs @@ -0,0 +1,267 @@ +using System; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using GoDaddy.Asherah.AppEncryption.Core; +using GoDaddy.Asherah.AppEncryption.Kms; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Metastore; +using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Kms; +using GoDaddy.Asherah.Crypto; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.Core +{ + public class SessionFactoryTests + { + private const string TestPartitionId = "test_partition_id"; + private const string TestServiceId = "test_service_id"; + private const string TestProductId = "test_product_id"; + + private readonly LoggerFactoryStub _loggerFactory = new(); + + private static GoDaddy.Asherah.AppEncryption.Core.SessionFactory NewSessionFactory( + InMemoryKeyMetastore metastore, + BasicExpiringCryptoPolicy cryptoPolicy, + IKeyManagementService keyManagementService, + ILogger logger) + { + return GoDaddy.Asherah.AppEncryption.Core.SessionFactory + .NewBuilder(TestProductId, TestServiceId) + .WithKeyMetastore(metastore) + .WithCryptoPolicy(cryptoPolicy) + .WithKeyManagementService(keyManagementService) + .WithLogger(logger) + .Build(); + } + + private GoDaddy.Asherah.AppEncryption.Core.SessionFactory NewSessionFactory( + bool canCacheSessions = false) + { + var metastore = new InMemoryKeyMetastore(); + var keyManagementService = new StaticKeyManagementService(); + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(true) + .WithCanCacheSystemKeys(true) + .WithCanCacheSessions(canCacheSessions) + .Build(); + var logger = _loggerFactory.CreateLogger(nameof(SessionFactoryTests)); + return NewSessionFactory(metastore, cryptoPolicy, keyManagementService, logger); + } + + [Fact] + public void GetSession_ReturnsNonNullSession() + { + using var factory = NewSessionFactory(); + using var session = factory.GetSession(TestPartitionId); + + Assert.NotNull(session); + } + + [Fact] + public void EncryptDecrypt_WithDefaults_Sync() + { + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = Encoding.UTF8.GetBytes(inputValue); + + using var factory = NewSessionFactory(); + using var session = factory.GetSession(TestPartitionId); + var dataRowRecordBytes = session.Encrypt(inputBytes); + + ValidateDataRowRecordJson(dataRowRecordBytes); + + var decryptedBytes = session.Decrypt(dataRowRecordBytes); + var outputValue = Encoding.UTF8.GetString(decryptedBytes); + + Assert.Equal(inputValue, outputValue); + } + + [Fact] + public async Task EncryptDecrypt_WithDefaults_Async() + { + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = Encoding.UTF8.GetBytes(inputValue); + + using var factory = NewSessionFactory(); + using var session = factory.GetSession(TestPartitionId); + var dataRowRecordBytes = await session.EncryptAsync(inputBytes); + + ValidateDataRowRecordJson(dataRowRecordBytes); + + var decryptedBytes = await session.DecryptAsync(dataRowRecordBytes); + var outputValue = Encoding.UTF8.GetString(decryptedBytes); + + Assert.Equal(inputValue, outputValue); + } + + [Fact] + public async Task EncryptDecrypt_MultipleTimes_WithDefaults() + { + const string inputValue = "The quick brown fox jumps over the lazy dog"; + var inputBytes = Encoding.UTF8.GetBytes(inputValue); + const string inputValue2 = "Lorem ipsum dolor sit amet"; + var inputBytes2 = Encoding.UTF8.GetBytes(inputValue2); + + using var factory = NewSessionFactory(); + using var session = factory.GetSession(TestPartitionId); + var dataRowRecordBytes = await session.EncryptAsync(inputBytes); + var dataRowRecordBytes2 = await session.EncryptAsync(inputBytes2); + + ValidateDataRowRecordJson(dataRowRecordBytes); + ValidateDataRowRecordJson(dataRowRecordBytes2); + + var decryptedBytes = await session.DecryptAsync(dataRowRecordBytes); + Assert.Equal(inputValue, Encoding.UTF8.GetString(decryptedBytes)); + + var decryptedBytes2 = await session.DecryptAsync(dataRowRecordBytes2); + Assert.Equal(inputValue2, Encoding.UTF8.GetString(decryptedBytes2)); + } + + [Fact] + public async Task EncryptDecrypt_WithDifferentInstances_SameMetastoreAndKms() + { + var keyManagementService = new StaticKeyManagementService(); + var metastore = new InMemoryKeyMetastore(); + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(true) + .WithCanCacheSystemKeys(true) + .WithCanCacheSessions(false) + .Build(); + var logger = _loggerFactory.CreateLogger(nameof(SessionFactoryTests)); + + const string inputValue = "shared metastore test"; + var inputBytes = Encoding.UTF8.GetBytes(inputValue); + + byte[] dataRowRecordBytes; + using (var factory1 = NewSessionFactory(metastore, cryptoPolicy, keyManagementService, logger)) + using (var session1 = factory1.GetSession(TestPartitionId)) + { + dataRowRecordBytes = await session1.EncryptAsync(inputBytes); + ValidateDataRowRecordJson(dataRowRecordBytes); + } + + using (var factory2 = NewSessionFactory(metastore, cryptoPolicy, keyManagementService, logger)) + using (var session2 = factory2.GetSession(TestPartitionId)) + { + var decryptedBytes = await session2.DecryptAsync(dataRowRecordBytes); + Assert.Equal(inputValue, Encoding.UTF8.GetString(decryptedBytes)); + } + + keyManagementService.Dispose(); + metastore.Dispose(); + } + + [Fact] + public void GetSession_DifferentPartitionIds_ReturnSessionsThatEncryptIndependently() + { + const string inputValue = "partition isolation"; + var inputBytes = Encoding.UTF8.GetBytes(inputValue); + + using var factory = NewSessionFactory(); + using var sessionA = factory.GetSession("partitionA"); + using var sessionB = factory.GetSession("partitionB"); + + var encryptedA = sessionA.Encrypt(inputBytes); + var encryptedB = sessionB.Encrypt(inputBytes); + + ValidateDataRowRecordJson(encryptedA); + ValidateDataRowRecordJson(encryptedB); + + Assert.Equal(inputValue, Encoding.UTF8.GetString(sessionA.Decrypt(encryptedA))); + Assert.Equal(inputValue, Encoding.UTF8.GetString(sessionB.Decrypt(encryptedB))); + } + + [Fact] + public void GetSession_WhenCachingEnabled_SamePartitionReturnsCachedSession() + { + using var metastore = new InMemoryKeyMetastore(); + using var keyManagementService = new StaticKeyManagementService(); + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheIntermediateKeys(true) + .WithCanCacheSystemKeys(true) + .WithCanCacheSessions(true) + .Build(); + var logger = _loggerFactory.CreateLogger(nameof(SessionFactoryTests)); + + const string inputValue = "cached session test"; + var inputBytes = Encoding.UTF8.GetBytes(inputValue); + + using (var factory = NewSessionFactory(metastore, cryptoPolicy, keyManagementService, logger)) + { + byte[] encrypted; + using (var session1 = factory.GetSession(TestPartitionId)) + { + encrypted = session1.Encrypt(inputBytes); + ValidateDataRowRecordJson(encrypted); + } + + using (var session2 = factory.GetSession(TestPartitionId)) + { + var decrypted = session2.Decrypt(encrypted); + Assert.Equal(inputValue, Encoding.UTF8.GetString(decrypted)); + } + } + } + + [Fact] + public void Dispose_AfterGetSession_DoesNotThrow() + { + using var metastore = new InMemoryKeyMetastore(); + using var keyManagementService = new StaticKeyManagementService(); + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheSessions(false) + .Build(); + var logger = _loggerFactory.CreateLogger(nameof(SessionFactoryTests)); + + var factory = NewSessionFactory(metastore, cryptoPolicy, keyManagementService, logger); + using (var session = factory.GetSession(TestPartitionId)) + { + _ = session.Encrypt(Encoding.UTF8.GetBytes("dispose test")); + } + + factory.Dispose(); + } + + [Fact] + public void Dispose_WithoutAnyOperations_DoesNotThrow() + { + using var metastore = new InMemoryKeyMetastore(); + using var keyManagementService = new StaticKeyManagementService(); + var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() + .WithKeyExpirationDays(30) + .WithRevokeCheckMinutes(30) + .WithCanCacheSessions(false) + .Build(); + var logger = _loggerFactory.CreateLogger(nameof(SessionFactoryTests)); + + var factory = NewSessionFactory(metastore, cryptoPolicy, keyManagementService, logger); + factory.Dispose(); + } + + private static void ValidateDataRowRecordJson(byte[] dataRowRecordBytes) + { + var dataRowObject = JsonNode.Parse(dataRowRecordBytes); + Assert.NotNull(dataRowObject); + Assert.NotNull(dataRowObject["Key"]); + Assert.Equal(JsonValueKind.Object, dataRowObject["Key"]?.GetValueKind()); + Assert.NotNull(dataRowObject["Data"]); + Assert.Equal(JsonValueKind.String, dataRowObject["Data"]?.GetValueKind()); + Assert.NotNull(dataRowObject["Key"]?["Created"]); + Assert.Equal(JsonValueKind.Number, dataRowObject["Key"]?["Created"]?.GetValueKind()); + Assert.NotNull(dataRowObject["Key"]?["Key"]); + Assert.Equal(JsonValueKind.String, dataRowObject["Key"]?["Key"]?.GetValueKind()); + Assert.NotNull(dataRowObject["Key"]?["ParentKeyMeta"]); + Assert.Equal(JsonValueKind.Object, dataRowObject["Key"]?["ParentKeyMeta"]?.GetValueKind()); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs index 464de39da..f5bac9542 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Envelope/EnvelopeEncryptionTests.cs @@ -11,6 +11,7 @@ using GoDaddy.Asherah.AppEncryption.Kms; using GoDaddy.Asherah.AppEncryption.Metastore; using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Metastore; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Kms; using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers.Dummy; using GoDaddy.Asherah.Crypto; using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; @@ -34,7 +35,7 @@ private EnvelopeEncryption NewEnvelopeEncryption( { metastore ??= new InMemoryKeyMetastore(); var logger = _loggerFactory.CreateLogger("EnvelopeEncryptionTests"); - keyManagementService ??= new DummyKeyManagementService(); + keyManagementService ??= new StaticKeyManagementService(); var crypto = new BouncyAes256GcmCrypto(); partition ??= _partition; @@ -126,7 +127,7 @@ public async Task EncryptDecrypt_MultipleTimes_WithDefaults() [Fact] public async Task EncryptDecrypt_WithDifferentInstances() { - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() .WithKeyExpirationDays(30) @@ -161,7 +162,7 @@ public async Task EncryptDecrypt_WithDifferentInstances() [Fact] public async Task Decrypt_Throws_When_IntermediateKey_Cannot_Be_Found() { - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() .WithKeyExpirationDays(30) .WithRevokeCheckMinutes(30) @@ -284,7 +285,7 @@ await Assert.ThrowsAsync(exceptionType, async () => public async Task InlineRotation_CreatesNewKeys_WhenExistingKeysAreExpired() { // Arrange: Use a shared metastore and KMS with a test partition - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("rotationTest", "testService", "testProduct"); @@ -345,7 +346,7 @@ public async Task InlineRotation_WithRevokedKey_CreatesNewKey() { // This test verifies that revoked keys trigger rotation // We'll use a helper metastore that we can manipulate to simulate a revoked key scenario - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("revokedKeyTest", "testService", "testProduct"); @@ -375,7 +376,7 @@ public async Task DuplicateKeyCreation_ConcurrentEncryption_BothSucceed() { // This test verifies that when two concurrent encryption operations try to create keys, // both succeed (one creates, one uses the created key via retry logic) - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("concurrentTest", "testService", "testProduct"); @@ -428,7 +429,7 @@ public async Task DuplicateKeyCreation_SequentialEncryption_ReusesSameKey() { // This test verifies that sequential encryption operations on the same partition // reuse the same key (no duplicate creation) - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("sequentialTest", "testService", "testProduct"); @@ -471,7 +472,7 @@ public async Task DuplicateKeyCreation_SequentialEncryption_ReusesSameKey() public async Task DuplicateKeyCreation_MultipleConcurrentOperations_AllSucceed() { // This test verifies that many concurrent operations all succeed - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("multiConcurrentTest", "testService", "testProduct"); @@ -534,7 +535,7 @@ public async Task DuplicateKeyCreation_MultipleConcurrentOperations_AllSucceed() public async Task InlineRotation_UsesExistingKey_WhenNotExpired() { // Arrange: Use a shared metastore and KMS - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var cryptoPolicy = BasicExpiringCryptoPolicy.NewBuilder() @@ -568,7 +569,7 @@ public async Task InlineRotation_UsesExistingKey_WhenNotExpired() public void Dispose_AfterNormalOperations_DoesNotThrow() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("disposeTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: true, canCacheIntermediateKeys: true); @@ -628,7 +629,7 @@ public void Dispose_CalledMultipleTimes_DoesNotThrow() public async Task InlineRotation_WithConfigurablePolicy_CreatesNewIntermediateKey() { // Arrange: Real implementations except for configurable crypto policy - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("inlineRotationTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); @@ -672,7 +673,7 @@ public async Task InlineRotation_WithConfigurablePolicy_CreatesNewIntermediateKe public async Task InlineRotation_WithConfigurablePolicy_CreatesNewSystemKey() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("systemKeyRotationTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); @@ -716,7 +717,7 @@ public async Task InlineRotation_WithConfigurablePolicy_CreatesNewSystemKey() public async Task InlineRotation_MultipleRotations_AllKeysRemainDecryptable() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("multiRotationTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); @@ -768,7 +769,7 @@ public async Task InlineRotation_MultipleRotations_AllKeysRemainDecryptable() public async Task InlineRotation_KeyNotExpired_ReusesExistingKey() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("noRotationTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); @@ -804,7 +805,7 @@ public async Task WithExistingSystemKey_CanCacheSystemKeysTrue_CachesSystemKey() // Arrange: CanCacheSystemKeys=true, CanCacheIntermediateKeys=false // This forces GetIntermediateKey to be called on every decrypt, // which in turn calls WithExistingSystemKey. The SK should be cached after first call. - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("systemKeyCacheTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: true, canCacheIntermediateKeys: false); @@ -839,7 +840,7 @@ public async Task WithExistingSystemKey_CanCacheSystemKeysTrue_CachesSystemKey() public async Task WithExistingSystemKey_RevokedSystemKey_TreatExpiredAsMissingTrue_LogsWarningAndCreatesNewKeys() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("revokedSkTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); @@ -900,7 +901,7 @@ public async Task WithExistingSystemKey_RevokedSystemKey_TreatExpiredAsMissingTr public async Task WithExistingSystemKey_CanCacheSystemKeysTrue_UsesCachedKeyForMultipleIKs() { // Arrange: CanCacheSystemKeys=true, CanCacheIntermediateKeys=false - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("systemKeyCacheMultiIKTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: true, canCacheIntermediateKeys: false); @@ -959,7 +960,7 @@ public async Task WithIntermediateKeyForRead_CanCacheIntermediateKeysTrue_Caches { // Arrange: CanCacheIntermediateKeys=true, CanCacheSystemKeys=false // This ensures we're testing IK caching specifically - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("ikCacheTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: true); @@ -991,7 +992,7 @@ public async Task WithIntermediateKeyForRead_CanCacheIntermediateKeysTrue_Caches public async Task WithIntermediateKeyForRead_CanCacheIntermediateKeysTrue_UsesCachedKeyForMultipleDecrypts() { // Arrange: CanCacheIntermediateKeys=true - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("ikCacheMultiDecryptTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: true); @@ -1036,7 +1037,7 @@ public async Task WithIntermediateKeyForRead_CanCacheIntermediateKeysTrue_UsesCa public async Task GetLatestOrCreateIntermediateKey_DuplicateDetected_UsesRetryLogic() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("duplicateIkTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); @@ -1070,7 +1071,7 @@ public async Task GetLatestOrCreateIntermediateKey_DuplicateDetected_UsesRetryLo public async Task GetLatestOrCreateSystemKey_DuplicateDetected_UsesRetryLogic() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("duplicateSkTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); @@ -1106,7 +1107,7 @@ public async Task GetLatestOrCreateSystemKey_DuplicateDetected_UsesRetryLogic() public async Task GetIntermediateKey_NullParentKeyMeta_ThrowsMetadataMissingException() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("nullParentKeyMetaReadTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); @@ -1148,7 +1149,7 @@ public async Task GetIntermediateKey_NullParentKeyMeta_ThrowsMetadataMissingExce public async Task GetSystemKey_SystemKeyNotFound_ThrowsMetadataMissingException() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("missingSkTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); @@ -1189,7 +1190,7 @@ public async Task GetSystemKey_SystemKeyNotFound_ThrowsMetadataMissingException( public async Task GetLatestOrCreateIntermediateKey_WithNullParentKeyMeta_LogsWarningAndCreatesNewKey() { // Arrange - var keyManagementService = new DummyKeyManagementService(); + var keyManagementService = new StaticKeyManagementService(); var metastore = new InMemoryKeyMetastore(); var partition = new DefaultPartition("nullParentKeyMetaTest", "testService", "testProduct"); var cryptoPolicy = new ConfigurableCryptoPolicy(canCacheSystemKeys: false, canCacheIntermediateKeys: false); diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Json/AppJsonEncryptionImplTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Json/AppJsonEncryptionImplTest.cs index 467172aca..138cb66af 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Json/AppJsonEncryptionImplTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/Json/AppJsonEncryptionImplTest.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using GoDaddy.Asherah.AppEncryption.Envelope; using GoDaddy.Asherah.AppEncryption.Persistence; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Kms; using GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers.Dummy; using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; using GoDaddy.Asherah.Crypto.Keys; @@ -18,7 +19,7 @@ public class AppJsonEncryptionImplTest : IClassFixture, IDisposa private readonly InMemoryMetastoreImpl metastore; private readonly AdhocPersistence dataPersistence; private readonly DefaultPartition partition; - private readonly DummyKeyManagementService keyManagementService; + private readonly StaticKeyManagementService keyManagementService; public AppJsonEncryptionImplTest() { @@ -30,7 +31,7 @@ public AppJsonEncryptionImplTest() (key, jsonObject) => memoryPersistence.Add(key, jsonObject)); metastore = new InMemoryMetastoreImpl(); - keyManagementService = new DummyKeyManagementService(); + keyManagementService = new StaticKeyManagementService(); BouncyAes256GcmCrypto aeadEnvelopeCrypto = new BouncyAes256GcmCrypto(); diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/SessionFactoryTest.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/SessionFactoryTest.cs index 238cb09d4..17c14fd19 100644 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/SessionFactoryTest.cs +++ b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/SessionFactoryTest.cs @@ -7,6 +7,7 @@ using GoDaddy.Asherah.AppEncryption.Envelope; using GoDaddy.Asherah.AppEncryption.Kms; using GoDaddy.Asherah.AppEncryption.Persistence; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Kms; using GoDaddy.Asherah.AppEncryption.Util; using GoDaddy.Asherah.Crypto; using GoDaddy.Asherah.Crypto.Keys; @@ -111,7 +112,7 @@ public void TestSessionCacheGetSessionWhileStillUsedAndNotExpiredShouldNotEvict( using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithMetastore(metastoreSpy.Object) .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { using (Session sessionBytes = factory.GetSessionBytes(TestPartitionId)) @@ -151,7 +152,7 @@ public void TestSessionCacheGetSessionWhileStillUsedAndExpiredShouldNotEvict() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithMetastore(metastoreSpy.Object) .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { using (Session sessionBytes = factory.GetSessionBytes(TestPartitionId)) @@ -202,7 +203,7 @@ public void TestSessionCacheGetSessionAfterUseAndNotExpiredShouldNotEvict() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithMetastore(metastoreSpy.Object) .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { byte[] payload = { 0, 1, 2, 3, 4, 5, 6, 7 }; @@ -252,7 +253,7 @@ public void TestSessionCacheGetSessionAfterUseAndExpiredShouldEvict() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithMetastore(metastoreSpy.Object) .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { byte[] payload = { 0, 1, 2, 3, 4, 5, 6, 7 }; @@ -306,7 +307,7 @@ public void TestSessionCacheGetSessionWithMaxSessionNotReachedShouldNotEvict() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithMetastore(metastoreSpy.Object) .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { byte[] payload = { 0, 1, 2, 3, 4, 5, 6, 7 }; @@ -364,7 +365,7 @@ public void TestSessionCacheGetSessionWithMaxSessionReachedShouldEvict() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithMetastore(metastoreSpy.Object) .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { byte[] payload = { 0, 1, 2, 3, 4, 5, 6, 7 }; @@ -419,7 +420,7 @@ public void TestSessionCacheGetSessionWithMaxSessionReachedButStillUsedShouldNot using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithMetastore(metastoreSpy.Object) .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { using (Session sessionBytes = factory.GetSessionBytes(TestPartitionId)) @@ -466,7 +467,7 @@ public void TestSessionCacheMultiThreadedSameSessionNoEviction() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { int numThreads = 100; @@ -510,7 +511,7 @@ public void TestSessionCacheMultiThreadedDifferentSessionsNoEviction() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { int numThreads = 100; @@ -556,7 +557,7 @@ public void TestSessionCacheMultiThreadedWithMaxSessionReachedSameSession() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { int numThreads = 100; @@ -600,7 +601,7 @@ public void TestSessionCacheMultiThreadedWithMaxSessionReachedDifferentSessions( using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { int numThreads = 100; @@ -643,7 +644,7 @@ public void TestSessionCacheMultiThreadedWithExpirationSameSession() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithMetastore(metastoreSpy.Object) .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { int numThreads = 100; @@ -692,7 +693,7 @@ public void TestSessionCacheMultiThreadedWithExpirationDifferentSessions() using (SessionFactory factory = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithMetastore(metastoreSpy.Object) .WithCryptoPolicy(policy) - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build()) { int numThreads = 100; @@ -824,7 +825,7 @@ public void TestBuilderPathWithPrebuiltInterfaces() Assert.NotNull(keyManagementServiceStep); SessionFactory.IBuildStep buildStep = - keyManagementServiceStep.WithStaticKeyManagementService(TestStaticMasterKey); + keyManagementServiceStep.WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)); Assert.NotNull(buildStep); SessionFactory sessionFactory = buildStep.Build(); @@ -848,7 +849,7 @@ public void TestBuilderPathWithSpecifiedInterfaces() cryptoPolicyStep.WithCryptoPolicy(cryptoPolicy); Assert.NotNull(keyManagementServiceStep); - KeyManagementService keyManagementService = new StaticKeyManagementServiceImpl(TestStaticMasterKey); + IKeyManagementService keyManagementService = new StaticKeyManagementService(TestStaticMasterKey); SessionFactory.IBuildStep buildStep = keyManagementServiceStep.WithKeyManagementService(keyManagementService); Assert.NotNull(buildStep); @@ -863,7 +864,7 @@ public void TestBuilderPathWithMetricsDisabled() SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithNeverExpiredCryptoPolicy() - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build(); MetricsUtil.MetricsInstance.Measure.Meter.Mark(new MeterOptions { Name = "should.not.record" }, 1); @@ -879,7 +880,7 @@ public void TestBuilderPathWithMetricsEnabled() SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithNeverExpiredCryptoPolicy() - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .WithMetrics(metrics) .Build(); @@ -896,7 +897,7 @@ public void TestBuilderPathWithLoggerEnabled() SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithNeverExpiredCryptoPolicy() - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .WithLogger(mockLogger.Object) .Build(); @@ -910,7 +911,7 @@ public void TestBuilderPathWithLoggerDisabled() SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithNeverExpiredCryptoPolicy() - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .Build(); // Verify the SessionFactory was created successfully without a logger @@ -934,7 +935,7 @@ public void TestBuilderPathWithLoggerInFullChain() Assert.NotNull(keyManagementServiceStep); SessionFactory.IBuildStep buildStep = - keyManagementServiceStep.WithStaticKeyManagementService(TestStaticMasterKey); + keyManagementServiceStep.WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)); Assert.NotNull(buildStep); SessionFactory.IBuildStep buildStepWithLogger = buildStep.WithLogger(mockLogger.Object); @@ -953,7 +954,7 @@ public void TestBuilderPathWithLoggerAndMetrics() SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithNeverExpiredCryptoPolicy() - .WithStaticKeyManagementService(TestStaticMasterKey) + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)) .WithLogger(mockLogger.Object) .WithMetrics(metrics) .Build(); @@ -970,7 +971,7 @@ public void TestWithLoggerReturnsCorrectInterface() SessionFactory.IBuildStep buildStep = SessionFactory.NewBuilder(TestProductId, TestServiceId) .WithInMemoryMetastore() .WithNeverExpiredCryptoPolicy() - .WithStaticKeyManagementService(TestStaticMasterKey); + .WithKeyManagementService(new StaticKeyManagementService(TestStaticMasterKey)); SessionFactory.IBuildStep result = buildStep.WithLogger(mockLogger.Object); diff --git a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/Dummy/DummyKeyManagementService.cs b/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/Dummy/DummyKeyManagementService.cs deleted file mode 100644 index 2c95bd301..000000000 --- a/csharp/AppEncryption/AppEncryption.Tests/AppEncryption/TestHelpers/Dummy/DummyKeyManagementService.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using GoDaddy.Asherah.AppEncryption.Kms; -using GoDaddy.Asherah.Crypto.Engine.BouncyCastle; -using GoDaddy.Asherah.Crypto.Keys; - -namespace GoDaddy.Asherah.AppEncryption.Tests.AppEncryption.TestHelpers.Dummy -{ - public sealed class DummyKeyManagementService : KeyManagementService, IDisposable - { - private readonly CryptoKey encryptionKey; - private readonly BouncyAes256GcmCrypto crypto = new BouncyAes256GcmCrypto(); - - public DummyKeyManagementService() - { - encryptionKey = crypto.GenerateKey(); - } - - public override byte[] EncryptKey(CryptoKey key) - { - return crypto.EncryptKey(key, encryptionKey); - } - - public override CryptoKey DecryptKey(byte[] keyCipherText, DateTimeOffset keyCreated, bool revoked) - { - return crypto.DecryptKey(keyCipherText, keyCreated, encryptionKey, revoked); - } - - public override string ToString() - { - return typeof(DummyKeyManagementService).FullName + "[kms_arn=LOCAL, crypto=" + crypto + "]"; - } - - /// - /// Disposes of the managed resources. - /// - public void Dispose() - { - Dispose(true); - } - - /// - /// Disposes of the managed resources. - /// - /// True if called from Dispose, false if called from finalizer. - private void Dispose(bool disposing) - { - if (disposing) - { - encryptionKey?.Dispose(); - crypto?.Dispose(); - } - } - } -} diff --git a/csharp/AppEncryption/AppEncryption/Kms/StaticKeyManagementServiceImpl.cs b/csharp/AppEncryption/AppEncryption/Kms/StaticKeyManagementServiceImpl.cs index 0f800b666..26806e139 100644 --- a/csharp/AppEncryption/AppEncryption/Kms/StaticKeyManagementServiceImpl.cs +++ b/csharp/AppEncryption/AppEncryption/Kms/StaticKeyManagementServiceImpl.cs @@ -14,6 +14,7 @@ namespace GoDaddy.Asherah.AppEncryption.Kms /// encrypt/decrypt keys. /// Note: This should never be used in a production environment. /// + [Obsolete("Use StaticKeyManagementService from GoDaddy.Asherah.AppEncryption.PlugIns.Testing for testing. This will be removed in a future release.")] public class StaticKeyManagementServiceImpl : KeyManagementService, IDisposable { private readonly SecretCryptoKey encryptionKey; diff --git a/csharp/AppEncryption/AppEncryption/SessionFactory.cs b/csharp/AppEncryption/AppEncryption/SessionFactory.cs index afb4ded88..8f5df45d2 100644 --- a/csharp/AppEncryption/AppEncryption/SessionFactory.cs +++ b/csharp/AppEncryption/AppEncryption/SessionFactory.cs @@ -153,15 +153,15 @@ public interface ICryptoPolicyStep public interface IKeyManagementServiceStep { /// - /// Initialize a session factory builder step with a new - /// object. NOTE: Leaving this in here for now for user integration test convenience. Need to add "don't - /// run in prod" checks somehow. + /// Initialize a session factory builder step with a new static key management service. + /// Use with StaticKeyManagementService from + /// GoDaddy.Asherah.AppEncryption.PlugIns.Testing for testing instead. /// /// /// The static key. /// - /// The current instance initialized with a - /// object. + /// The current instance. + [Obsolete("Use WithKeyManagementService with StaticKeyManagementService from GoDaddy.Asherah.AppEncryption.PlugIns.Testing for testing. This will be removed in a future release.")] IBuildStep WithStaticKeyManagementService(string staticMasterKey); /// @@ -540,9 +540,12 @@ public IKeyManagementServiceStep WithCryptoPolicy(CryptoPolicy policy) return this; } + [Obsolete("Use WithKeyManagementService with StaticKeyManagementService from GoDaddy.Asherah.AppEncryption.PlugIns.Testing for testing. This will be removed in a future release.")] public IBuildStep WithStaticKeyManagementService(string staticMasterKey) { +#pragma warning disable CS0618 // Obsolete: keeping until we remove this method keyManagementService = new StaticKeyManagementServiceImpl(staticMasterKey); +#pragma warning restore CS0618 return this; } diff --git a/csharp/AppEncryption/README.md b/csharp/AppEncryption/README.md index 7a366c4e8..c588989f0 100644 --- a/csharp/AppEncryption/README.md +++ b/csharp/AppEncryption/README.md @@ -36,7 +36,8 @@ Our libraries currently target netstandard2.0, net8.0, net9.0. ```c# // Create a session factory. The builder steps used below are for testing only. -var staticKeyManagementService = new StaticKeyManagementServiceImpl("thisIsAStaticMasterKeyForTestingOnly"); +// Use StaticKeyManagementService from the GoDaddy.Asherah.AppEncryption.PlugIns.Testing package. +var staticKeyManagementService = new StaticKeyManagementService("thisIsAStaticMasterKeyForTestingOnly"); using (SessionFactory sessionFactory = SessionFactory .NewBuilder("some_product", "some_service") @@ -250,7 +251,8 @@ public class MyService(AWSOptions awsOptions, KeyManagementServiceOptions kmsOpt #### Static KMS (FOR TESTING ONLY) ```c# -KeyManagementService keyManagementService = new StaticKeyManagementServiceImpl("thisIsAStaticMasterKeyForTesting"); +// Use StaticKeyManagementService from the GoDaddy.Asherah.AppEncryption.PlugIns.Testing package. +IKeyManagementService keyManagementService = new StaticKeyManagementService("thisIsAStaticMasterKeyForTesting"); ``` ### Define the Crypto Policy diff --git a/tartufo.toml b/tartufo.toml index 029f2ab05..1b939d186 100644 --- a/tartufo.toml +++ b/tartufo.toml @@ -56,6 +56,9 @@ exclude-signatures = [ { 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 = "d443501bf2d6eaa001dbe362f23e5d547b460f878b0455aaed36549c4fc8a415", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"}, + { signature = "086e498d8eda0c6de875f45ce13fb715f468d1d6c56e2e0f13dba0c9d9ae13b6", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"}, + { signature = "3896f078bd2434d7d70a38934e314f25215f90be0dbb745b6d9a7156231cf601", 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 ea98a6acaad0ddb25073592422fb59d7cb2905f0 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sun, 15 Mar 2026 05:52:20 -0700 Subject: [PATCH 30/31] Add Regression/Metastore integration tests: DynamoDbGlobalTableTest (plugins) and MetastoreCompatibilityTest (legacy/core cross-decrypt) Made-with: Cursor --- .../AppEncryption.IntegrationTests.csproj | 1 + .../Metastore/DynamoDbGlobalTableTest.cs | 163 +++++++++ .../Metastore/MetastoreCompatibilityTest.cs | 326 ++++++++++++++++++ tartufo.toml | 3 + 4 files changed, 493 insertions(+) create mode 100644 csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Metastore/DynamoDbGlobalTableTest.cs create mode 100644 csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Metastore/MetastoreCompatibilityTest.cs diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj index 180e0ab09..69f1adb8c 100644 --- a/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/AppEncryption.IntegrationTests.csproj @@ -33,6 +33,7 @@ + diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Metastore/DynamoDbGlobalTableTest.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Metastore/DynamoDbGlobalTableTest.cs new file mode 100644 index 000000000..d50750e4d --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Metastore/DynamoDbGlobalTableTest.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using GoDaddy.Asherah.AppEncryption.Core; +using GoDaddy.Asherah.AppEncryption.IntegrationTests.Utils; +using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore; +using GoDaddy.Asherah.AppEncryption.Tests.Fixtures; +using Xunit; + +namespace GoDaddy.Asherah.AppEncryption.IntegrationTests.Regression.Metastore +{ + [Collection("Configuration collection")] + public class DynamoDbGlobalTableTest : IClassFixture, IDisposable + { + private const string PartitionKey = "Id"; + private const string SortKey = "Created"; + private const string DefaultTableName = "EncryptionKey"; + private const string DefaultRegion = "us-west-2"; + + private readonly ConfigFixture _configFixture; + private readonly string _serviceUrl; + private readonly AmazonDynamoDBClient _tempDynamoDbClient; + + public DynamoDbGlobalTableTest(DynamoDbContainerFixture dynamoDbContainerFixture, ConfigFixture configFixture) + { + _serviceUrl = dynamoDbContainerFixture.GetServiceUrl(); + _configFixture = configFixture; + + var amazonDynamoDbConfig = new AmazonDynamoDBConfig + { + ServiceURL = _serviceUrl, + AuthenticationRegion = DefaultRegion, + }; + _tempDynamoDbClient = new AmazonDynamoDBClient(amazonDynamoDbConfig); + var request = new CreateTableRequest + { + TableName = DefaultTableName, + AttributeDefinitions = new List + { + new AttributeDefinition(PartitionKey, ScalarAttributeType.S), + new AttributeDefinition(SortKey, ScalarAttributeType.N), + }, + KeySchema = new List + { + new KeySchemaElement(PartitionKey, KeyType.HASH), + new KeySchemaElement(SortKey, KeyType.RANGE), + }, + ProvisionedThroughput = new ProvisionedThroughput(1L, 1L), + }; + _tempDynamoDbClient.CreateTableAsync(request).Wait(); + } + + public void Dispose() + { + try + { + _tempDynamoDbClient?.DeleteTableAsync(DefaultTableName).Wait(); + } + catch (AggregateException) + { + // Table may not exist. + } + } + + private GoDaddy.Asherah.AppEncryption.Core.SessionFactory GetSessionFactory(bool withKeySuffix, string region) + { + var options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = DefaultTableName, + KeySuffix = withKeySuffix ? region : string.Empty, + }; + + // Reuse the same client so both "regions" hit the same table (global table simulation). + var metastore = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(_tempDynamoDbClient) + .WithOptions(options) + .Build(); + + return CoreSessionFactoryGenerator.CreateDefaultSessionFactory( + _configFixture.KeyManagementService, + metastore); + } + + [Fact] + private void TestRegionSuffix() + { + byte[] originalPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecordBytes; + byte[] decryptedBytes; + + using (var sessionFactory = GetSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = sessionFactory.GetSession("shopper123")) + { + dataRowRecordBytes = session.Encrypt(originalPayload); + } + } + + using (var sessionFactory = GetSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = sessionFactory.GetSession("shopper123")) + { + decryptedBytes = session.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedBytes); + } + + [Fact] + private void TestRegionSuffixBackwardCompatibility() + { + byte[] originalPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecordBytes; + byte[] decryptedBytes; + + using (var sessionFactory = GetSessionFactory(false, DefaultRegion)) + { + using (IEncryptionSession session = sessionFactory.GetSession("shopper123")) + { + dataRowRecordBytes = session.Encrypt(originalPayload); + } + } + + using (var sessionFactory = GetSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = sessionFactory.GetSession("shopper123")) + { + decryptedBytes = session.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedBytes); + } + + [Fact] + private void TestCrossRegionDecryption() + { + byte[] originalPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecordBytes; + byte[] decryptedBytes; + + using (var sessionFactory = GetSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = sessionFactory.GetSession("shopper123")) + { + dataRowRecordBytes = session.Encrypt(originalPayload); + } + } + + using (var sessionFactory = GetSessionFactory(true, "us-east-1")) + { + using (IEncryptionSession session = sessionFactory.GetSession("shopper123")) + { + decryptedBytes = session.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedBytes); + } + } +} diff --git a/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Metastore/MetastoreCompatibilityTest.cs b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Metastore/MetastoreCompatibilityTest.cs new file mode 100644 index 000000000..a10ef8d74 --- /dev/null +++ b/csharp/AppEncryption/AppEncryption.IntegrationTests/Regression/Metastore/MetastoreCompatibilityTest.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using Amazon.DynamoDBv2; +using Amazon.DynamoDBv2.Model; +using GoDaddy.Asherah.AppEncryption.Core; +using GoDaddy.Asherah.AppEncryption.IntegrationTests.Utils; +using GoDaddy.Asherah.AppEncryption.Persistence; +using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore; +using GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Kms; +using GoDaddy.Asherah.AppEncryption.Tests.Fixtures; +using Xunit; + +using static GoDaddy.Asherah.AppEncryption.IntegrationTests.TestHelpers.Constants; + +namespace GoDaddy.Asherah.AppEncryption.IntegrationTests.Regression.Metastore +{ + [Collection("Configuration collection")] + public class MetastoreCompatibilityTest : IClassFixture, IDisposable + { + private const string PartitionKey = "Id"; + private const string SortKey = "Created"; + private const string DefaultTableName = "EncryptionKey"; + private const string DefaultRegion = "us-west-2"; + private const string OtherRegion = "us-east-1"; + + private readonly AmazonDynamoDBClient _dynamoDbClient; + private readonly string _serviceUrl; + private readonly StaticKeyManagementService _keyManagementService; + + public MetastoreCompatibilityTest(DynamoDbContainerFixture dynamoDbContainerFixture) + { + _serviceUrl = dynamoDbContainerFixture.GetServiceUrl(); + + _dynamoDbClient = new AmazonDynamoDBClient(new AmazonDynamoDBConfig + { + ServiceURL = _serviceUrl, + AuthenticationRegion = DefaultRegion, + }); + + var createTableRequest = new CreateTableRequest + { + TableName = DefaultTableName, + AttributeDefinitions = new List + { + new AttributeDefinition(PartitionKey, ScalarAttributeType.S), + new AttributeDefinition(SortKey, ScalarAttributeType.N), + }, + KeySchema = new List + { + new KeySchemaElement(PartitionKey, KeyType.HASH), + new KeySchemaElement(SortKey, KeyType.RANGE), + }, + ProvisionedThroughput = new ProvisionedThroughput(1L, 1L), + }; + _dynamoDbClient.CreateTableAsync(createTableRequest).Wait(); + + _keyManagementService = new StaticKeyManagementService(KeyManagementStaticMasterKey); + } + + private SessionFactory GetLegacySessionFactory(bool withKeySuffix, string region) + { + DynamoDbMetastoreImpl.IBuildStep builder = DynamoDbMetastoreImpl.NewBuilder(region) + .WithEndPointConfiguration(_serviceUrl, DefaultRegion) + .WithTableName(DefaultTableName); + + if (withKeySuffix) + { + builder = builder.WithKeySuffix(); + } + + DynamoDbMetastoreImpl metastore = builder.Build(); + return SessionFactoryGenerator.CreateDefaultSessionFactory(_keyManagementService, metastore); + } + + private GoDaddy.Asherah.AppEncryption.Core.SessionFactory GetCoreSessionFactory(bool withKeySuffix, string region) + { + var options = new DynamoDbMetastoreOptions + { + KeyRecordTableName = DefaultTableName, + KeySuffix = withKeySuffix ? region : string.Empty, + }; + var metastore = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(_dynamoDbClient) + .WithOptions(options) + .Build(); + return CoreSessionFactoryGenerator.CreateDefaultSessionFactory(_keyManagementService, metastore); + } + + [Fact] + private void TestRegionSuffixLegacyToCore() + { + byte[] originalPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecordBytes; + + using (SessionFactory legacy = GetLegacySessionFactory(true, DefaultRegion)) + { + using (var sessionBytes = legacy.GetSessionBytes("shopper123")) + { + dataRowRecordBytes = sessionBytes.Encrypt(originalPayload); + } + } + + byte[] decryptedBytes; + using (var core = GetCoreSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = core.GetSession("shopper123")) + { + decryptedBytes = session.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedBytes); + + byte[] decryptedAgainBytes; + using (SessionFactory legacy = GetLegacySessionFactory(true, DefaultRegion)) + { + using (var sessionBytes = legacy.GetSessionBytes("shopper123")) + { + decryptedAgainBytes = sessionBytes.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedAgainBytes); + } + + [Fact] + private void TestRegionSuffixCoreToLegacy() + { + byte[] originalPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecordBytes; + + using (var core = GetCoreSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = core.GetSession("shopper123")) + { + dataRowRecordBytes = session.Encrypt(originalPayload); + } + } + + byte[] decryptedBytes; + using (var legacy = GetLegacySessionFactory(true, DefaultRegion)) + { + using (var sessionBytes = legacy.GetSessionBytes("shopper123")) + { + decryptedBytes = sessionBytes.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedBytes); + + byte[] decryptedAgainBytes; + using (var core = GetCoreSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = core.GetSession("shopper123")) + { + decryptedAgainBytes = session.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedAgainBytes); + } + + [Fact] + private void TestRegionSuffixBackwardCompatibilityLegacyToCore() + { + byte[] originalPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecordBytes; + + using (SessionFactory legacy = GetLegacySessionFactory(false, DefaultRegion)) + { + using (var sessionBytes = legacy.GetSessionBytes("shopper123")) + { + dataRowRecordBytes = sessionBytes.Encrypt(originalPayload); + } + } + + byte[] decryptedBytes; + using (var core = GetCoreSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = core.GetSession("shopper123")) + { + decryptedBytes = session.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedBytes); + + byte[] decryptedAgainBytes; + using (SessionFactory legacy = GetLegacySessionFactory(false, DefaultRegion)) + { + using (var sessionBytes = legacy.GetSessionBytes("shopper123")) + { + decryptedAgainBytes = sessionBytes.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedAgainBytes); + } + + [Fact] + private void TestRegionSuffixBackwardCompatibilityCoreToLegacy() + { + byte[] originalPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecordBytes; + + using (var core = GetCoreSessionFactory(false, DefaultRegion)) + { + using (IEncryptionSession session = core.GetSession("shopper123")) + { + dataRowRecordBytes = session.Encrypt(originalPayload); + } + } + + byte[] decryptedBytes; + using (var legacy = GetLegacySessionFactory(true, DefaultRegion)) + { + using (var sessionBytes = legacy.GetSessionBytes("shopper123")) + { + decryptedBytes = sessionBytes.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedBytes); + + byte[] decryptedAgainBytes; + using (var core = GetCoreSessionFactory(false, DefaultRegion)) + { + using (IEncryptionSession session = core.GetSession("shopper123")) + { + decryptedAgainBytes = session.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedAgainBytes); + } + + [Fact] + private void TestCrossRegionDecryptionLegacyToCore() + { + byte[] originalPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecordBytes; + + using (SessionFactory legacy = GetLegacySessionFactory(true, DefaultRegion)) + { + using (var sessionBytes = legacy.GetSessionBytes("shopper123")) + { + dataRowRecordBytes = sessionBytes.Encrypt(originalPayload); + } + } + + byte[] decryptedBytes; + using (var core = GetCoreSessionFactory(true, OtherRegion)) + { + using (IEncryptionSession session = core.GetSession("shopper123")) + { + decryptedBytes = session.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedBytes); + + byte[] decryptedAgainBytes; + using (SessionFactory legacy = GetLegacySessionFactory(true, DefaultRegion)) + { + using (var sessionBytes = legacy.GetSessionBytes("shopper123")) + { + decryptedAgainBytes = sessionBytes.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedAgainBytes); + } + + [Fact] + private void TestCrossRegionDecryptionCoreToLegacy() + { + byte[] originalPayload = PayloadGenerator.CreateDefaultRandomBytePayload(); + byte[] dataRowRecordBytes; + + using (var core = GetCoreSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = core.GetSession("shopper123")) + { + dataRowRecordBytes = session.Encrypt(originalPayload); + } + } + + byte[] decryptedBytes; + using (var legacy = GetLegacySessionFactory(true, OtherRegion)) + { + using (var sessionBytes = legacy.GetSessionBytes("shopper123")) + { + decryptedBytes = sessionBytes.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedBytes); + + byte[] decryptedAgainBytes; + using (var core = GetCoreSessionFactory(true, DefaultRegion)) + { + using (IEncryptionSession session = core.GetSession("shopper123")) + { + decryptedAgainBytes = session.Decrypt(dataRowRecordBytes); + } + } + + Assert.Equal(originalPayload, decryptedAgainBytes); + } + + public void Dispose() + { + _keyManagementService?.Dispose(); + try + { + _dynamoDbClient?.DeleteTableAsync(DefaultTableName).Wait(); + } + catch (AggregateException) + { + // Table may not exist. + } + + _dynamoDbClient?.Dispose(); + } + } +} diff --git a/tartufo.toml b/tartufo.toml index 1b939d186..2dc0fe3ca 100644 --- a/tartufo.toml +++ b/tartufo.toml @@ -59,6 +59,8 @@ exclude-signatures = [ { signature = "d443501bf2d6eaa001dbe362f23e5d547b460f878b0455aaed36549c4fc8a415", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"}, { signature = "086e498d8eda0c6de875f45ce13fb715f468d1d6c56e2e0f13dba0c9d9ae13b6", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"}, { signature = "3896f078bd2434d7d70a38934e314f25215f90be0dbb745b6d9a7156231cf601", reason = "High entropy findings (dummy encryoted payloads, fake keys, method names, etc.) in C# tests"}, + { signature = "dc0fb2464d4d00d122106036f31e628ceedfa5231e0aab2942c5cfea33db9848", reason = "High entropy findings (dummy payloads, fake keys, etc.) in C# MetastoreCompatibilityTest"}, + { signature = "ef125e46e699f84efac57caea87446602d1d05b6703815afd9d2781a6e69113d", reason = "High entropy findings (dummy payloads, fake keys, etc.) in C# MetastoreCompatibilityTest"}, { 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"}, @@ -86,6 +88,7 @@ exclude-signatures = [ ] exclude-path-patterns = [ + {path-pattern = '.*/Regression/Metastore/MetastoreCompatibilityTest\.cs', reason = 'C# integration test: dummy payloads, static KMS key reference'}, {path-pattern = '.*/(L|LL)P64/.*', reason = 'Exclude LP64 paths'}, {path-pattern = '.*\.DotSettings', reason = 'Exclude DotSettings files'}, {path-pattern = '.*/go.mod', reason = 'Exclude go.mod files'}, From b50822a5c1664fc1347ce79ed4d59fa5b1468110 Mon Sep 17 00:00:00 2001 From: chief-micco Date: Sun, 15 Mar 2026 06:11:14 -0700 Subject: [PATCH 31/31] docs: add SessionFactory upgrade guide and KeyMetastore section to plugins guide Made-with: Cursor --- .../docs/plugins-upgrade-guide.md | 74 ++++++++ .../docs/sessionfactory-upgrade-guide.md | 178 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 csharp/AppEncryption/docs/sessionfactory-upgrade-guide.md diff --git a/csharp/AppEncryption/docs/plugins-upgrade-guide.md b/csharp/AppEncryption/docs/plugins-upgrade-guide.md index 22382eee8..a421ba4a8 100644 --- a/csharp/AppEncryption/docs/plugins-upgrade-guide.md +++ b/csharp/AppEncryption/docs/plugins-upgrade-guide.md @@ -5,6 +5,7 @@ This guide provides step-by-step instructions for upgrading from obsolete plugin ## Table of Contents - [Upgrading to the new KeyManagementService Plugin](#upgrading-to-the-new-keymanagementservice-plugin) +- [Upgrading to the new KeyMetastore Plugin](#upgrading-to-the-new-keymetastore-plugin) ## Upgrading to the new KeyManagementService Plugin @@ -164,3 +165,76 @@ var keyManagementService = KeyManagementService.NewBuilder() - 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 + +## Upgrading to the new KeyMetastore Plugin + +The legacy DynamoDB metastore (`DynamoDbMetastoreImpl`) implements `IMetastore` and is used with the legacy `SessionFactory` in `GoDaddy.Asherah.AppEncryption`. The new KeyMetastore plugin (`DynamoDbMetastore` in `GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore`) implements `IKeyMetastore`, which is a breaking interface change: the new interface uses async methods, key records (`IKeyRecord`), and a different storage contract. **Because of this, upgrading to the new KeyMetastore plugin requires upgrading to the new SessionFactory** that accepts `IKeyMetastore` (in `GoDaddy.Asherah.AppEncryption.Core`). You cannot use the new KeyMetastore plugin with the legacy SessionFactory. + +Follow the [SessionFactory Upgrade Guide](sessionfactory-upgrade-guide.md) to migrate to the Core `SessionFactory` and `IKeyMetastore`. Then switch your metastore implementation to the new plugin as below. + +### Key Changes + +1. **Interface**: `IMetastore` (sync, JSON values) → `IKeyMetastore` (async, `IKeyRecord` values) +2. **Namespace**: Legacy DynamoDB metastore lives in `GoDaddy.Asherah.AppEncryption.Persistence`; the new plugin is in `GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore` +3. **Construction**: Region-based builder → builder that takes an `IAmazonDynamoDB` client and `DynamoDbMetastoreOptions` (e.g. table name, key suffix) +4. **SessionFactory**: Must use `GoDaddy.Asherah.AppEncryption.Core.SessionFactory` with `WithKeyMetastore()`; the legacy `SessionFactory` does not accept `IKeyMetastore` + +### Migration Steps + +#### Step 1: Upgrade to the new SessionFactory + +Complete the migration described in the [SessionFactory Upgrade Guide](sessionfactory-upgrade-guide.md). Your code will use `GoDaddy.Asherah.AppEncryption.Core.SessionFactory` and `IKeyMetastore` instead of the legacy factory and `IMetastore`. + +#### Step 2: Replace DynamoDbMetastoreImpl with DynamoDbMetastore + +**Old (legacy):** +```c# +using GoDaddy.Asherah.AppEncryption.Persistence; + +IMetastore metastore = DynamoDbMetastoreImpl.NewBuilder("us-west-2") + .WithTableName("EncryptionKey") + .WithRegion("us-west-2") + .Build(); + +// Used with legacy SessionFactory +SessionFactory sessionFactory = SessionFactory.NewBuilder("product", "service") + .WithMetastore(metastore) + .WithCryptoPolicy(cryptoPolicy) + .WithKeyManagementService(keyManagementService) + .Build(); +``` + +**New:** +```c# +using GoDaddy.Asherah.AppEncryption.Core; +using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore; + +var options = new DynamoDbMetastoreOptions +{ + KeyRecordTableName = "EncryptionKey", + KeySuffix = "" // optional; use for regional key suffix (e.g. global tables) +}; + +IKeyMetastore keyMetastore = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(amazonDynamoDbClient) + .WithOptions(options) + .Build(); + +SessionFactory sessionFactory = SessionFactory.NewBuilder("product", "service") + .WithKeyMetastore(keyMetastore) + .WithCryptoPolicy(cryptoPolicy) + .WithKeyManagementService(keyManagementService) + .WithLogger(logger) + .Build(); +``` + +You are responsible for creating and configuring `IAmazonDynamoDB` (region, credentials, endpoint, etc.). The new plugin does not construct the client from a region string. + +#### Step 3: Table schema + +The new and old DynamoDB metastore implementations expect the **exact same schema**. They are compatible: the new `DynamoDbMetastore` plugin will work with your existing key table with no schema changes. + +### Summary + +- **Upgrade order:** Migrate to the new SessionFactory and `IKeyMetastore` first (see [SessionFactory Upgrade Guide](sessionfactory-upgrade-guide.md)), then replace the legacy DynamoDB metastore with `DynamoDbMetastore` from `GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore`. +- The new KeyMetastore plugin cannot be used with the legacy `SessionFactory` or `IMetastore`-based code paths. diff --git a/csharp/AppEncryption/docs/sessionfactory-upgrade-guide.md b/csharp/AppEncryption/docs/sessionfactory-upgrade-guide.md new file mode 100644 index 000000000..993e894ee --- /dev/null +++ b/csharp/AppEncryption/docs/sessionfactory-upgrade-guide.md @@ -0,0 +1,178 @@ +# SessionFactory Upgrade Guide + +This guide provides step-by-step instructions for upgrading from the legacy `SessionFactory` (and `IMetastore`-based stack) to the new `SessionFactory` in the Core namespace that uses `IKeyMetastore`. + +## Table of Contents + +- [Overview](#overview) +- [Key Changes](#key-changes) +- [Migration Steps](#migration-steps) +- [Complete Migration Example](#complete-migration-example) +- [Session API Changes](#session-api-changes) +- [Additional Notes](#additional-notes) + +## Overview + +The new `SessionFactory` lives in the `GoDaddy.Asherah.AppEncryption.Core` namespace and is built around `IKeyMetastore` instead of `IMetastore`. The key metastore interface uses key records and async operations, and the new session factory exposes only byte-based encryption sessions (`IEncryptionSession`). There is no direct compatibility between the old and new APIs; upgrading requires switching metastore, builder, and session usage together. + +Applications are expected to use **one** of the two session factories (either the legacy or the Core). Using both in the same application is not recommended. + +## Key Changes + +| Area | Old (Legacy) | New (Core) | +|------|--------------|------------| +| **Namespace** | `GoDaddy.Asherah.AppEncryption` | `GoDaddy.Asherah.AppEncryption.Core` | +| **Metastore** | `IMetastore` (sync, JSON values) | `IKeyMetastore` (async, `IKeyRecord` values) | +| **Builder** | `WithMetastore()`, `WithInMemoryMetastore()` | `WithKeyMetastore()` (required) | +| **Logger** | Optional via `WithLogger()` | Required via `WithLogger()` | +| **Session types** | `GetSessionBytes()`, `GetSessionJson()`, `GetSessionJsonAsJson()`, `GetSessionBytesAsJson()` | `GetSession(partitionId)` only | +| **Session return** | `Session` (generic) | `IEncryptionSession` (byte[] encrypt/decrypt only) | +| **Metrics** | `WithMetrics(IMetrics)` on builder | Not available on new builder | + +## Migration Steps + +### Step 1: Use the Core namespace and builder + +**Old:** +```c# +using GoDaddy.Asherah.AppEncryption; + +SessionFactory sessionFactory = SessionFactory.NewBuilder("product", "service") + .WithMetastore(metastore) // IMetastore + .WithCryptoPolicy(cryptoPolicy) + .WithKeyManagementService(keyManagementService) + .Build(); +``` + +**New:** +```c# +using GoDaddy.Asherah.AppEncryption.Core; + +SessionFactory sessionFactory = SessionFactory.NewBuilder("product", "service") + .WithKeyMetastore(keyMetastore) // IKeyMetastore + .WithCryptoPolicy(cryptoPolicy) + .WithKeyManagementService(keyManagementService) + .WithLogger(logger) // Required + .Build(); +``` + +### Step 2: Replace the metastore with an IKeyMetastore implementation + +The legacy factory uses `IMetastore` (e.g. `DynamoDbMetastoreImpl`, `AdoMetastoreImpl`, `InMemoryMetastoreImpl`). The new factory requires `IKeyMetastore`. + +- **DynamoDB:** Use the new plugin `GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore.DynamoDbMetastore` (see [Plugins Upgrade Guide – KeyMetastore](plugins-upgrade-guide.md#upgrading-to-the-new-keymetastore-plugin)). +- **In-memory (e.g. tests):** Use `GoDaddy.Asherah.AppEncryption.PlugIns.Testing.Metastore.InMemoryKeyMetastore`. + +There is no adapter from `IMetastore` to `IKeyMetastore`; the interfaces differ (sync vs async, `JObject` vs `IKeyRecord`). You must use an `IKeyMetastore` implementation. + +### Step 3: Provide a logger + +The new builder requires a logger. Pass `ILogger` from your logging abstraction (e.g. `Microsoft.Extensions.Logging`) via `WithLogger(logger)`. + +### Step 4: Replace session creation and usage + +**Old (bytes-only usage):** +```c# +Session session = sessionFactory.GetSessionBytes("partition_id"); +byte[] encrypted = session.Encrypt(payload); +byte[] decrypted = session.Decrypt(encrypted); +``` + +**New:** +```c# +using (IEncryptionSession session = sessionFactory.GetSession("partition_id")) +{ + byte[] encrypted = session.Encrypt(payload); + byte[] decrypted = session.Decrypt(encrypted); +} +``` + +The new API does not offer `GetSessionJson`, `GetSessionJsonAsJson`, or `GetSessionBytesAsJson`. If you need JSON payloads, serialize to/from byte[] (e.g. UTF-8) and use `GetSession` + `Encrypt`/`Decrypt`. + +**Old (JSON session):** +```c# +Session session = sessionFactory.GetSessionJson("partition_id"); +byte[] encrypted = session.Encrypt(jObjectPayload); +``` + +**New (equivalent):** +```c# +// Serialize to bytes, then encrypt +byte[] payloadBytes = Encoding.UTF8.GetBytes(jObjectPayload.ToString()); +using (IEncryptionSession session = sessionFactory.GetSession("partition_id")) +{ + byte[] encrypted = session.Encrypt(payloadBytes); +} +``` + +### Step 5: Remove or replace metrics usage + +The Core `SessionFactory` builder has no `WithMetrics()` method. Remove metrics configuration from the factory build, or handle metrics elsewhere (e.g. application-level or wrapper). + +## Complete Migration Example + +**Before (legacy):** +```c# +using GoDaddy.Asherah.AppEncryption; +using GoDaddy.Asherah.AppEncryption.Persistence; + +IMetastore metastore = DynamoDbMetastoreImpl.NewBuilder("us-west-2") + .Build(); + +SessionFactory sessionFactory = SessionFactory.NewBuilder("my_product", "my_service") + .WithMetastore(metastore) + .WithCryptoPolicy(new NeverExpiredCryptoPolicy()) + .WithKeyManagementService(keyManagementService) + .WithLogger(logger) + .Build(); + +Session session = sessionFactory.GetSessionBytes("user_123"); +byte[] drr = session.Encrypt(plaintext); +byte[] decrypted = session.Decrypt(drr); +session.Dispose(); +sessionFactory.Dispose(); +``` + +**After (Core):** +```c# +using GoDaddy.Asherah.AppEncryption.Core; +using GoDaddy.Asherah.AppEncryption.PlugIns.Aws.Metastore; + +IKeyMetastore keyMetastore = DynamoDbMetastore.NewBuilder() + .WithDynamoDbClient(amazonDynamoDbClient) + .WithOptions(new DynamoDbMetastoreOptions { KeyRecordTableName = "EncryptionKey" }) + .Build(); + +SessionFactory sessionFactory = SessionFactory.NewBuilder("my_product", "my_service") + .WithKeyMetastore(keyMetastore) + .WithCryptoPolicy(new NeverExpiredCryptoPolicy()) + .WithKeyManagementService(keyManagementService) + .WithLogger(logger) + .Build(); + +using (IEncryptionSession session = sessionFactory.GetSession("user_123")) +{ + byte[] drr = session.Encrypt(plaintext); + byte[] decrypted = session.Decrypt(drr); +} +sessionFactory.Dispose(); +``` + +## Session API Changes + +| Legacy `Session` | New `IEncryptionSession` | +|----------------------------------|---------------------------| +| `Encrypt(byte[] payload)` | `Encrypt(byte[] payload)` | +| `Decrypt(byte[] dataRowRecord)` | `Decrypt(byte[] dataRowRecord)` | +| — | `EncryptAsync(byte[] payload)` | +| — | `DecryptAsync(byte[] dataRowRecord)` | +| `IDisposable` | `IDisposable` | + +The new session is byte-only; JSON-oriented workflows must be handled by serializing/deserializing outside the session. + +## Additional Notes + +- **One factory per application:** Use either the legacy or the Core SessionFactory in an application, not both. +- **In-memory testing:** Use `InMemoryKeyMetastore` from the `GoDaddy.Asherah.AppEncryption.PlugIns.Testing` package when building the Core `SessionFactory` in tests. +- **KeyMetastore plugins:** Migrating to the new KeyMetastore plugin (e.g. DynamoDB) implies using the new SessionFactory, since those plugins implement `IKeyMetastore`, which only the Core factory accepts. See the [Plugins Upgrade Guide – Upgrading to the new KeyMetastore Plugin](plugins-upgrade-guide.md#upgrading-to-the-new-keymetastore-plugin). +- **Dispose:** Both the session and the session factory should be disposed when no longer needed; prefer `using` for sessions.