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;
+}