From b784c7117ff73334aa8ad8b0ea84185ee066aa0e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:34:46 -0400 Subject: [PATCH] feat(secrets): AES-256-GCM envelope cipher with AAD binding --- .../SecretsExceptions.cs | 12 +- .../Crypto/AesGcmEnvelopeCipher.cs | 156 ++++++++++++++++++ .../Crypto/AesGcmEnvelopeCipherTests.cs | 109 ++++++++++++ .../Fakes/FakeMasterKeyProvider.cs | 45 +++++ 4 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Crypto/AesGcmEnvelopeCipher.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FakeMasterKeyProvider.cs diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs index f1d1869..b09302f 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs @@ -4,7 +4,17 @@ namespace ZB.MOM.WW.Secrets.Abstractions; public sealed class MasterKeyUnavailableException(string message) : InvalidOperationException(message); /// Thrown when a secret's ciphertext cannot be decrypted or its authentication tag fails to verify. -public sealed class SecretDecryptionException(string message) : InvalidOperationException(message); +public sealed class SecretDecryptionException : InvalidOperationException +{ + /// Creates the exception with a message describing the decryption failure. + /// The failure description. + public SecretDecryptionException(string message) : base(message) { } + + /// Creates the exception, preserving the underlying cryptographic fault. + /// The failure description. + /// The original cryptographic exception that caused the failure. + public SecretDecryptionException(string message, Exception innerException) : base(message, innerException) { } +} /// Thrown when a requested secret does not exist (or is tombstoned) in the store. public sealed class SecretNotFoundException(string message) : InvalidOperationException(message); diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Crypto/AesGcmEnvelopeCipher.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Crypto/AesGcmEnvelopeCipher.cs new file mode 100644 index 0000000..c5d580c --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/Crypto/AesGcmEnvelopeCipher.cs @@ -0,0 +1,156 @@ +using System.Security.Cryptography; +using System.Text; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Crypto; + +/// +/// The default : AES-256-GCM envelope encryption. +/// +/// +/// +/// Each secret is sealed under a fresh, per-secret 32-byte data-encryption key (DEK). The +/// DEK encrypts the UTF-8 plaintext with AES-256-GCM (12-byte nonce, 16-byte tag), binding the +/// secret as additional authenticated data (AAD) so ciphertext +/// cannot be silently relocated to a different name. The DEK is then wrapped with a second +/// AES-256-GCM operation under the master key (KEK) from an , +/// binding the provider's as AAD so a row can only be +/// unwrapped by the KEK that is claimed to have sealed it. +/// +/// +/// GCM's authentication tag provides constant-time integrity verification, so no additional +/// constant-time comparison is required. Every cryptographic failure path — a mismatched tag, +/// a wrong or unknown KEK, or any other — is surfaced as a +/// ; the cipher never returns a wrong plaintext and never +/// leaks a raw cryptographic exception. The transient DEK buffer is always zeroed with +/// . +/// +/// +public sealed class AesGcmEnvelopeCipher : ISecretCipher +{ + private const int KeySizeBytes = 32; // AES-256 + private const int NonceSizeBytes = 12; // AES-GCM standard nonce + private const int TagSizeBytes = 16; // AES-GCM full tag + + private readonly IMasterKeyProvider _masterKeyProvider; + + /// + /// Creates a cipher that wraps and unwraps DEKs with the master key supplied by + /// . + /// + /// The source of the AES-256 master key (KEK) and its id. + public AesGcmEnvelopeCipher(IMasterKeyProvider masterKeyProvider) + { + _masterKeyProvider = masterKeyProvider ?? throw new ArgumentNullException(nameof(masterKeyProvider)); + } + + /// + public StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType) + { + ArgumentNullException.ThrowIfNull(plaintext); + + string kekId = _masterKeyProvider.KekId; + ReadOnlySpan masterKey = _masterKeyProvider.GetMasterKey().Span; + + byte[] dek = new byte[KeySizeBytes]; + try + { + RandomNumberGenerator.Fill(dek); + + // Layer 1: seal the plaintext under the DEK, binding the secret name as AAD. + byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + byte[] nonce = new byte[NonceSizeBytes]; + byte[] tag = new byte[TagSizeBytes]; + byte[] ciphertext = new byte[plaintextBytes.Length]; + byte[] nameAad = Encoding.UTF8.GetBytes(name.Value); + + RandomNumberGenerator.Fill(nonce); + using (var bodyGcm = new AesGcm(dek, TagSizeBytes)) + { + bodyGcm.Encrypt(nonce, plaintextBytes, ciphertext, tag, nameAad); + } + + // Layer 2: wrap the DEK under the master key, binding the KEK id as AAD. + byte[] wrapNonce = new byte[NonceSizeBytes]; + byte[] wrapTag = new byte[TagSizeBytes]; + byte[] wrappedDek = new byte[KeySizeBytes]; + byte[] kekAad = Encoding.UTF8.GetBytes(kekId); + + RandomNumberGenerator.Fill(wrapNonce); + using (var wrapGcm = new AesGcm(masterKey, TagSizeBytes)) + { + wrapGcm.Encrypt(wrapNonce, dek, wrappedDek, wrapTag, kekAad); + } + + return new StoredSecret + { + Name = name, + ContentType = contentType, + Ciphertext = ciphertext, + Nonce = nonce, + Tag = tag, + WrappedDek = wrappedDek, + WrapNonce = wrapNonce, + WrapTag = wrapTag, + KekId = kekId, + Revision = 0, + IsDeleted = false, + CreatedUtc = default, + UpdatedUtc = default, + }; + } + finally + { + CryptographicOperations.ZeroMemory(dek); + } + } + + /// + public string Decrypt(StoredSecret secret) + { + ArgumentNullException.ThrowIfNull(secret); + + string kekId = _masterKeyProvider.KekId; + if (!string.Equals(kekId, secret.KekId, StringComparison.Ordinal)) + { + throw new SecretDecryptionException($"Row wrapped by unknown KEK '{secret.KekId}'."); + } + + ReadOnlySpan masterKey = _masterKeyProvider.GetMasterKey().Span; + + byte[] dek = new byte[KeySizeBytes]; + try + { + // Unwrap the DEK under the master key, verifying the KEK-id AAD binding. + byte[] kekAad = Encoding.UTF8.GetBytes(secret.KekId); + using (var wrapGcm = new AesGcm(masterKey, TagSizeBytes)) + { + wrapGcm.Decrypt(secret.WrapNonce, secret.WrappedDek, secret.WrapTag, dek, kekAad); + } + + // Decrypt the body under the DEK, verifying the name AAD binding. + byte[] nameAad = Encoding.UTF8.GetBytes(secret.Name.Value); + byte[] plaintextBytes = new byte[secret.Ciphertext.Length]; + using (var bodyGcm = new AesGcm(dek, TagSizeBytes)) + { + bodyGcm.Decrypt(secret.Nonce, secret.Ciphertext, secret.Tag, plaintextBytes, nameAad); + } + + return Encoding.UTF8.GetString(plaintextBytes); + } + catch (AuthenticationTagMismatchException ex) + { + throw new SecretDecryptionException( + $"Failed to decrypt secret '{secret.Name.Value}': authentication tag mismatch.", ex); + } + catch (CryptographicException ex) + { + throw new SecretDecryptionException( + $"Failed to decrypt secret '{secret.Name.Value}'.", ex); + } + finally + { + CryptographicOperations.ZeroMemory(dek); + } + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs new file mode 100644 index 0000000..097af48 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs @@ -0,0 +1,109 @@ +using System.Text; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Crypto; +using ZB.MOM.WW.Secrets.Tests.Fakes; + +namespace ZB.MOM.WW.Secrets.Tests.Crypto; + +public class AesGcmEnvelopeCipherTests +{ + [Fact] + public void RoundTrips() + { + var provider = new FakeMasterKeyProvider("k1"); + var cipher = new AesGcmEnvelopeCipher(provider); + var name = new SecretName("sql/foo"); + + StoredSecret row = cipher.Encrypt(name, "hunter2", SecretContentType.Text); + + Assert.Equal("k1", row.KekId); + // Ciphertext must not leak the plaintext in the clear. + byte[] plaintextBytes = Encoding.UTF8.GetBytes("hunter2"); + Assert.False(Contains(row.Ciphertext, plaintextBytes)); + + string decrypted = cipher.Decrypt(row); + Assert.Equal("hunter2", decrypted); + } + + [Fact] + public void TamperedCiphertextThrows() + { + var provider = new FakeMasterKeyProvider("k1"); + var cipher = new AesGcmEnvelopeCipher(provider); + var name = new SecretName("sql/foo"); + + StoredSecret row = cipher.Encrypt(name, "hunter2", SecretContentType.Text); + row.Ciphertext[0] ^= 0xFF; + + Assert.Throws(() => cipher.Decrypt(row)); + } + + [Fact] + public void AadBindingRejectsRenamedRow() + { + var provider = new FakeMasterKeyProvider("k1"); + var cipher = new AesGcmEnvelopeCipher(provider); + + StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text); + var renamed = row with { Name = new SecretName("sql/bar") }; + + Assert.Throws(() => cipher.Decrypt(renamed)); + } + + [Fact] + public void WrongKekThrows() + { + // Both providers report KekId "k1" but hold different random key bytes. + var providerA = new FakeMasterKeyProvider("k1"); + var providerB = new FakeMasterKeyProvider("k1"); + var cipherA = new AesGcmEnvelopeCipher(providerA); + var cipherB = new AesGcmEnvelopeCipher(providerB); + + StoredSecret row = cipherA.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text); + + // Matching KekId string but wrong key material must still fail closed (unwrap tag mismatch). + Assert.Throws(() => cipherB.Decrypt(row)); + } + + [Fact] + public void UnknownKekIdThrows() + { + var providerK1 = new FakeMasterKeyProvider("k1"); + var providerK2 = new FakeMasterKeyProvider("k2"); + var cipherK1 = new AesGcmEnvelopeCipher(providerK1); + var cipherK2 = new AesGcmEnvelopeCipher(providerK2); + + StoredSecret row = cipherK1.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text); + + var ex = Assert.Throws(() => cipherK2.Decrypt(row)); + Assert.Contains("unknown KEK", ex.Message, StringComparison.Ordinal); + } + + private static bool Contains(byte[] haystack, byte[] needle) + { + if (needle.Length == 0 || haystack.Length < needle.Length) + { + return false; + } + + for (int i = 0; i <= haystack.Length - needle.Length; i++) + { + bool match = true; + for (int j = 0; j < needle.Length; j++) + { + if (haystack[i + j] != needle[j]) + { + match = false; + break; + } + } + + if (match) + { + return true; + } + } + + return false; + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FakeMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FakeMasterKeyProvider.cs new file mode 100644 index 0000000..ac2b737 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Fakes/FakeMasterKeyProvider.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Tests.Fakes; + +/// +/// A hand-written test double (the family does not use Moq). +/// Generates a random 32-byte master key once at construction and returns it stably, alongside +/// a caller-supplied . Two instances with the same string +/// still hold different key bytes, which lets a test prove that a matching KEK id but wrong key +/// material fails closed. +/// +public sealed class FakeMasterKeyProvider : IMasterKeyProvider +{ + private readonly byte[] _key; + + /// + /// Creates a provider with a freshly generated random 32-byte master key and the given + /// . + /// + /// The stable, non-secret key identifier this provider reports. + public FakeMasterKeyProvider(string kekId = "k1") + { + KekId = kekId; + _key = RandomNumberGenerator.GetBytes(32); + } + + /// + /// Creates a provider that reuses an explicit 32-byte key. Use this to build a second + /// provider with the same key bytes as another (or, by omitting it, a different random key). + /// + /// The stable, non-secret key identifier this provider reports. + /// The exact 32-byte master key material to use. + public FakeMasterKeyProvider(string kekId, byte[] key) + { + KekId = kekId; + _key = (byte[])key.Clone(); + } + + /// + public string KekId { get; } + + /// + public ReadOnlyMemory GetMasterKey() => _key; +}