diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/IMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/IMasterKeyProvider.cs
index dd2eb5c..97c813e 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/IMasterKeyProvider.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/IMasterKeyProvider.cs
@@ -21,4 +21,18 @@ public interface IMasterKeyProvider
/// to the KEK that wrapped their DEK.
///
string KekId { get; }
+
+ ///
+ /// Returns the key and its as one consistent, caller-owned snapshot. The
+ /// cipher takes exactly one snapshot per operation and disposes it (zeroing its key copy).
+ ///
+ ///
+ /// The default implementation reads and then copies
+ /// ; a provider whose source can change between those two reads (a
+ /// file or environment variable) should override it to resolve the key once and derive the id
+ /// from that same reading, as MasterKeyProviderBase does.
+ ///
+ /// A snapshot the caller must dispose.
+ /// The master key cannot be resolved.
+ MasterKeySnapshot GetSnapshot() => new(KekId, GetMasterKey().ToArray());
}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/MasterKeySnapshot.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/MasterKeySnapshot.cs
new file mode 100644
index 0000000..9019055
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/MasterKeySnapshot.cs
@@ -0,0 +1,41 @@
+using System.Security.Cryptography;
+
+namespace ZB.MOM.WW.Secrets.Abstractions;
+
+///
+/// One consistent reading of a master key (KEK): the 32 key bytes together with the
+/// that identifies those bytes. Obtained from
+/// once per cipher operation, so the id stamped on a
+/// row and the key that wraps it can never come from two different reads of a source that changed
+/// in between (a key file replaced mid-operation).
+///
+///
+/// The snapshot owns its key buffer: zeroes it with
+/// . Always dispose a snapshot
+/// (using var snapshot = provider.GetSnapshot();) and never retain past it.
+///
+public sealed class MasterKeySnapshot : IDisposable
+{
+ private readonly byte[] _key;
+
+ ///
+ /// Creates a snapshot that takes ownership of (it is zeroed on
+ /// ; pass a buffer nothing else uses).
+ ///
+ /// The non-secret id of .
+ /// The key bytes; ownership transfers to the snapshot.
+ public MasterKeySnapshot(string kekId, byte[] key)
+ {
+ KekId = kekId ?? throw new ArgumentNullException(nameof(kekId));
+ _key = key ?? throw new ArgumentNullException(nameof(key));
+ }
+
+ /// The non-secret identifier of .
+ public string KekId { get; }
+
+ /// The key bytes. Valid only until , which zeroes them.
+ public ReadOnlyMemory Key => _key;
+
+ /// Zeroes the key buffer.
+ public void Dispose() => CryptographicOperations.ZeroMemory(_key);
+}
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
index e1de368..056a7b4 100644
--- 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
@@ -22,8 +22,15 @@ namespace ZB.MOM.WW.Secrets.Crypto;
/// 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
-/// .
+/// leaks a raw cryptographic exception. A malformed row (a wrong-length nonce, tag or wrapped DEK,
+/// which reports as ) fails the same way. The
+/// transient DEK buffer is always zeroed with .
+///
+///
+/// Every operation takes exactly one per KEK
+/// (), so the KEK id stamped on or checked against a row
+/// and the key used for it always come from the same reading, and the snapshot's key copy is zeroed
+/// when the operation ends.
///
///
public sealed class AesGcmEnvelopeCipher : ISecretCipher
@@ -49,8 +56,9 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
{
ArgumentNullException.ThrowIfNull(plaintext);
- string kekId = _masterKeyProvider.KekId;
- ReadOnlySpan masterKey = _masterKeyProvider.GetMasterKey().Span;
+ using MasterKeySnapshot kek = _masterKeyProvider.GetSnapshot();
+ string kekId = kek.KekId;
+ ReadOnlySpan masterKey = kek.Key.Span;
byte[] dek = new byte[KeySizeBytes];
try
@@ -110,13 +118,13 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
{
ArgumentNullException.ThrowIfNull(secret);
- string kekId = _masterKeyProvider.KekId;
- if (!string.Equals(kekId, secret.KekId, StringComparison.Ordinal))
+ using MasterKeySnapshot kek = _masterKeyProvider.GetSnapshot();
+ if (!string.Equals(kek.KekId, secret.KekId, StringComparison.Ordinal))
{
throw new SecretDecryptionException($"Row wrapped by unknown KEK '{secret.KekId}'.");
}
- ReadOnlySpan masterKey = _masterKeyProvider.GetMasterKey().Span;
+ ReadOnlySpan masterKey = kek.Key.Span;
byte[] dek = new byte[KeySizeBytes];
try
@@ -148,6 +156,15 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
throw new SecretDecryptionException(
$"Failed to decrypt secret '{secret.Name.Value}'.", ex);
}
+ // AesGcm throws ArgumentException for a wrong-length nonce or tag, or a ciphertext/plaintext
+ // length mismatch (a WrappedDek that is not 32 bytes): a corrupt or truncated row. It is a
+ // decryption failure like any other, so callers (the resolver's decryption-failed audit)
+ // classify it correctly (Secrets-004). Mirrors Rewrap.
+ catch (ArgumentException ex)
+ {
+ throw new SecretDecryptionException(
+ $"Failed to decrypt secret '{secret.Name.Value}': the row is malformed.", ex);
+ }
finally
{
CryptographicOperations.ZeroMemory(dek);
@@ -161,18 +178,21 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
ArgumentNullException.ThrowIfNull(oldKek);
ArgumentNullException.ThrowIfNull(newKek);
+ using MasterKeySnapshot oldSnapshot = oldKek.GetSnapshot();
+
// Fail closed if the row is not actually wrapped by the supplied old KEK — a caller must
// not silently "rewrap" a row that is already on a different key.
- if (!string.Equals(secret.KekId, oldKek.KekId, StringComparison.Ordinal))
+ if (!string.Equals(secret.KekId, oldSnapshot.KekId, StringComparison.Ordinal))
{
throw new SecretDecryptionException(
$"Cannot rewrap secret '{secret.Name.Value}': row is wrapped by KEK '{secret.KekId}', " +
- $"not the supplied old KEK '{oldKek.KekId}'.");
+ $"not the supplied old KEK '{oldSnapshot.KekId}'.");
}
- ReadOnlySpan oldMasterKey = oldKek.GetMasterKey().Span;
- ReadOnlySpan newMasterKey = newKek.GetMasterKey().Span;
- string newKekId = newKek.KekId;
+ using MasterKeySnapshot newSnapshot = newKek.GetSnapshot();
+ ReadOnlySpan oldMasterKey = oldSnapshot.Key.Span;
+ ReadOnlySpan newMasterKey = newSnapshot.Key.Span;
+ string newKekId = newSnapshot.KekId;
byte[] dek = new byte[KeySizeBytes];
try
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs
index a317c81..1022b90 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs
@@ -1,3 +1,4 @@
+using System.Security.Cryptography;
using System.Text;
using ZB.MOM.WW.Secrets.Abstractions;
@@ -45,6 +46,7 @@ public sealed class FileMasterKeyProvider : MasterKeyProviderBase
// Otherwise treat the content as base64 text.
string text = Encoding.UTF8.GetString(raw).Trim();
+ CryptographicOperations.ZeroMemory(raw);
try
{
return Convert.FromBase64String(text);
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs
index 0f4e1d6..69d54a0 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs
@@ -13,6 +13,11 @@ namespace ZB.MOM.WW.Secrets.MasterKey;
/// file value takes effect without recreating the provider. is likewise derived
/// from the currently resolved key (unless an explicit id is configured), so it stays stable for a
/// given key and shifts only when the key itself changes.
+///
+/// resolves the source once and derives the id from that same
+/// reading, so a key file replaced mid-operation can never produce a row stamped with one key's id
+/// and wrapped under another (Secrets-007). Intermediate key buffers are zeroed.
+///
///
public abstract class MasterKeyProviderBase : IMasterKeyProvider
{
@@ -45,7 +50,32 @@ public abstract class MasterKeyProviderBase : IMasterKeyProvider
return explicitId;
}
- return DeriveKekId(ResolveValidatedKey());
+ byte[] key = ResolveValidatedKey();
+ try
+ {
+ return DeriveKekId(key);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(key);
+ }
+ }
+ }
+
+ ///
+ public MasterKeySnapshot GetSnapshot()
+ {
+ byte[] key = ResolveValidatedKey();
+ try
+ {
+ string? explicitId = _options.KekId;
+ string kekId = string.IsNullOrEmpty(explicitId) ? DeriveKekId(key) : explicitId;
+ return new MasterKeySnapshot(kekId, key);
+ }
+ catch
+ {
+ CryptographicOperations.ZeroMemory(key);
+ throw;
}
}
@@ -54,7 +84,10 @@ public abstract class MasterKeyProviderBase : IMasterKeyProvider
/// when the source is missing or malformed; length
/// validation is applied by the base class.
///
- /// The resolved key bytes (any length; validated by the caller).
+ ///
+ /// The resolved key bytes (any length; validated by the caller), in a freshly allocated
+ /// buffer the caller owns: the base class zeroes it after use, so never return a cached array.
+ ///
protected abstract byte[] ResolveKeyBytes();
private byte[] ResolveValidatedKey()
@@ -62,6 +95,7 @@ public abstract class MasterKeyProviderBase : IMasterKeyProvider
byte[] key = ResolveKeyBytes();
if (key.Length != KeySizeBytes)
{
+ CryptographicOperations.ZeroMemory(key);
throw new MasterKeyUnavailableException(
$"Master key must be exactly {KeySizeBytes} bytes (AES-256) but was {key.Length} bytes.");
}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/MalformedRowTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/MalformedRowTests.cs
new file mode 100644
index 0000000..d02d59f
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/MalformedRowTests.cs
@@ -0,0 +1,64 @@
+using ZB.MOM.WW.Audit;
+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;
+
+///
+/// Secrets-004: a row whose crypto fields have the wrong length (truncated by a buggy replication
+/// peer or a hand-edited database) must fail as — the only
+/// failure documents — never as a raw .
+///
+public class MalformedRowTests
+{
+ public static TheoryData Fields => new() { "Nonce", "Tag", "WrappedDek", "WrapNonce", "WrapTag" };
+
+ private static StoredSecret Truncate(StoredSecret row, string field) => field switch
+ {
+ "Nonce" => row with { Nonce = new byte[5] },
+ "Tag" => row with { Tag = new byte[3] },
+ "WrappedDek" => row with { WrappedDek = row.WrappedDek[..31] },
+ "WrapNonce" => row with { WrapNonce = new byte[5] },
+ "WrapTag" => row with { WrapTag = new byte[3] },
+ _ => throw new ArgumentOutOfRangeException(nameof(field)),
+ };
+
+ [Theory]
+ [MemberData(nameof(Fields))]
+ public void A_wrong_length_field_fails_as_a_decryption_error(string field)
+ {
+ var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
+ StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
+ Assert.Equal("hunter2", cipher.Decrypt(row)); // positive control: the untouched row decrypts
+
+ StoredSecret corrupt = Truncate(row, field);
+
+ var ex = Assert.Throws(() => cipher.Decrypt(corrupt));
+ Assert.IsAssignableFrom(ex.InnerException);
+ Assert.DoesNotContain("hunter2", ex.Message);
+ }
+
+ [Fact]
+ public async Task The_resolver_audits_a_malformed_row_as_decryption_failed()
+ {
+ var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
+ var store = new CountingSecretStore();
+ var audit = new CapturingAuditWriter();
+ var resolver = new DefaultSecretResolver(store, cipher, audit, TimeSpan.FromSeconds(30));
+ var name = new SecretName("sql/foo");
+ StoredSecret row = cipher.Encrypt(name, "hunter2", SecretContentType.Text) with
+ {
+ Revision = 1,
+ CreatedUtc = DateTimeOffset.UnixEpoch,
+ UpdatedUtc = DateTimeOffset.UnixEpoch,
+ };
+ store.Seed(row with { WrappedDek = row.WrappedDek[..31] });
+
+ await Assert.ThrowsAsync(() => resolver.GetAsync(name, CancellationToken.None));
+
+ AuditEvent evt = Assert.Single(audit.Events);
+ Assert.Equal("secret.resolve.decryption-failed", evt.Action);
+ Assert.Equal(AuditOutcome.Failure, evt.Outcome);
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/MasterKeySnapshotTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/MasterKeySnapshotTests.cs
new file mode 100644
index 0000000..0041334
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Crypto/MasterKeySnapshotTests.cs
@@ -0,0 +1,170 @@
+using System.Security.Cryptography;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.Crypto;
+using ZB.MOM.WW.Secrets.MasterKey;
+using ZB.MOM.WW.Secrets.Tests.Fakes;
+
+namespace ZB.MOM.WW.Secrets.Tests.Crypto;
+
+///
+/// Secrets-007: the cipher must read the KEK id and the key from ONE resolution of the source, and
+/// zero the key copy it used. Also pins the on-disk format: a row written by the pre-snapshot cipher
+/// (0.6.5) must still decrypt.
+///
+public class MasterKeySnapshotTests
+{
+ private static readonly byte[] KeyA = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
+ private static readonly byte[] KeyB = Enumerable.Range(100, 32).Select(i => (byte)i).ToArray();
+
+ ///
+ /// A file-like provider whose source is replaced after its first read: the first resolution
+ /// returns key A, every later one key B (an operator swapping the key file mid-operation).
+ ///
+ private sealed class SwappedOnceProvider : MasterKeyProviderBase
+ {
+ private int _reads;
+
+ public SwappedOnceProvider() : base(new MasterKeyOptions()) { }
+
+ public int Reads => _reads;
+
+ protected override byte[] ResolveKeyBytes() =>
+ (byte[])(Interlocked.Increment(ref _reads) == 1 ? KeyA : KeyB).Clone();
+ }
+
+ private sealed class FixedKeyProvider : MasterKeyProviderBase
+ {
+ private readonly byte[] _key;
+
+ public FixedKeyProvider(byte[] key) : base(new MasterKeyOptions()) => _key = key;
+
+ protected override byte[] ResolveKeyBytes() => (byte[])_key.Clone();
+ }
+
+ /// Records the key buffer of every snapshot it hands out.
+ private sealed class RecordingProvider(byte[] key, string kekId) : IMasterKeyProvider
+ {
+ public List Handed { get; } = new();
+
+ public string KekId => kekId;
+
+ public ReadOnlyMemory GetMasterKey() => (byte[])key.Clone();
+
+ public MasterKeySnapshot GetSnapshot()
+ {
+ byte[] copy = (byte[])key.Clone();
+ Handed.Add(copy);
+ return new MasterKeySnapshot(kekId, copy);
+ }
+ }
+
+ [Fact]
+ public void Encrypt_stamps_the_id_of_the_key_that_actually_wrapped_the_row()
+ {
+ var swapping = new SwappedOnceProvider();
+ var cipher = new AesGcmEnvelopeCipher(swapping);
+
+ StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
+
+ // One resolution per operation, not one for the id and another for the key.
+ Assert.Equal(1, swapping.Reads);
+
+ // The row claims key A and is wrapped by key A, so a node holding key A reads it.
+ var holderOfA = new AesGcmEnvelopeCipher(new FixedKeyProvider(KeyA));
+ Assert.Equal(new FixedKeyProvider(KeyA).KekId, row.KekId);
+ Assert.Equal("hunter2", holderOfA.Decrypt(row));
+ }
+
+ [Fact]
+ public void Decrypt_and_rewrap_resolve_each_kek_once()
+ {
+ var aCipher = new AesGcmEnvelopeCipher(new FixedKeyProvider(KeyA));
+ StoredSecret row = aCipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
+
+ var swapping = new SwappedOnceProvider();
+ Assert.Equal("hunter2", new AesGcmEnvelopeCipher(swapping).Decrypt(row));
+ Assert.Equal(1, swapping.Reads);
+
+ var oldKek = new SwappedOnceProvider();
+ var newKek = new FixedKeyProvider(KeyB);
+ StoredSecret rewrapped = aCipher.Rewrap(row, oldKek, newKek);
+ Assert.Equal(1, oldKek.Reads);
+ Assert.Equal("hunter2", new AesGcmEnvelopeCipher(new FixedKeyProvider(KeyB)).Decrypt(rewrapped));
+ }
+
+ [Fact]
+ public void The_cipher_zeroes_every_key_snapshot_it_used()
+ {
+ var provider = new RecordingProvider(KeyA, "k1");
+ var cipher = new AesGcmEnvelopeCipher(provider);
+
+ StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
+ Assert.Equal("hunter2", cipher.Decrypt(row));
+ cipher.Rewrap(row, provider, new RecordingProvider(KeyB, "k2"));
+
+ Assert.Equal(3, provider.Handed.Count);
+ Assert.All(provider.Handed, buffer => Assert.All(buffer, b => Assert.Equal(0, b)));
+ }
+
+ [Fact]
+ public void The_default_snapshot_copies_so_disposing_it_never_zeroes_the_providers_own_key()
+ {
+ byte[] key = (byte[])KeyA.Clone();
+ var provider = new FakeMasterKeyProvider("k1", key);
+ var cipher = new AesGcmEnvelopeCipher(provider);
+
+ StoredSecret row = cipher.Encrypt(new SecretName("sql/foo"), "hunter2", SecretContentType.Text);
+
+ // The provider (which hands out its cached array) still works after the cipher zeroed its snapshot.
+ Assert.Equal("hunter2", cipher.Decrypt(row));
+ Assert.Equal(KeyA, provider.GetMasterKey().ToArray());
+ }
+
+ [Fact]
+ public void A_row_written_by_the_0_6_5_cipher_still_decrypts()
+ {
+ // Generated with the 0.6.5 AesGcmEnvelopeCipher + FileMasterKeyProvider over a raw key file
+ // holding bytes 0..31 (derived KEK id). The format is unchanged by the snapshot work.
+ string keyPath = Path.Combine(Path.GetTempPath(), $"zb-secrets-golden-{Guid.NewGuid():N}.key");
+ File.WriteAllBytes(keyPath, KeyA);
+ try
+ {
+ var provider = new FileMasterKeyProvider(new MasterKeyOptions
+ {
+ Source = MasterKeySource.File,
+ FilePath = keyPath,
+ });
+ var row = new StoredSecret
+ {
+ Name = new SecretName("sql/golden"),
+ ContentType = SecretContentType.Text,
+ Ciphertext = Convert.FromHexString("65D1B5D4FD8F538D9B58A622E202D84CDACD1F17D809DEBA0E1575DB"),
+ Nonce = Convert.FromHexString("023EF785E6D7318EB0F1A238"),
+ Tag = Convert.FromHexString("AAFF7A1E634C74332D4D4B75B36DE293"),
+ WrappedDek = Convert.FromHexString("8BA7B8BD1A289CF97AE91386B72061C97A7CD7FF9487140590015A0ED46E79B2"),
+ WrapNonce = Convert.FromHexString("04B946E3103E0E523714DDA8"),
+ WrapTag = Convert.FromHexString("F542CA1BF350577B400E524B816D64A3"),
+ KekId = "sha256:630dcd2966c4",
+ Revision = 1,
+ CreatedUtc = DateTimeOffset.UnixEpoch,
+ UpdatedUtc = DateTimeOffset.UnixEpoch,
+ };
+
+ Assert.Equal("sha256:630dcd2966c4", provider.KekId);
+ Assert.Equal("correct horse battery staple", new AesGcmEnvelopeCipher(provider).Decrypt(row));
+ }
+ finally
+ {
+ File.Delete(keyPath);
+ }
+ }
+
+ [Fact]
+ public void Disposing_a_snapshot_zeroes_its_key()
+ {
+ byte[] key = RandomNumberGenerator.GetBytes(32);
+ var snapshot = new MasterKeySnapshot("k1", key);
+ snapshot.Dispose();
+ Assert.All(key, b => Assert.Equal(0, b));
+ }
+}