diff --git a/ZB.MOM.WW.Secrets/Directory.Packages.props b/ZB.MOM.WW.Secrets/Directory.Packages.props
index 0fb7b4c..84dca02 100644
--- a/ZB.MOM.WW.Secrets/Directory.Packages.props
+++ b/ZB.MOM.WW.Secrets/Directory.Packages.props
@@ -11,6 +11,9 @@
+
+
+
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/DpapiMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/DpapiMasterKeyProvider.cs
new file mode 100644
index 0000000..a9424ac
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/DpapiMasterKeyProvider.cs
@@ -0,0 +1,57 @@
+using System.Runtime.Versioning;
+using System.Security.Cryptography;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.MasterKey;
+
+///
+/// Resolves the master key (KEK) by unprotecting a DPAPI-sealed blob file at
+/// under the current-user scope. Windows-only.
+///
+///
+/// The file must contain the ciphertext produced by
+/// for the 32-byte key.
+/// The blob is read and unprotected fresh on every call. On a non-Windows OS
+/// throws .
+///
+[SupportedOSPlatform("windows")]
+public sealed class DpapiMasterKeyProvider : MasterKeyProviderBase
+{
+ /// Creates the provider bound to .
+ /// The master-key configuration (requires ).
+ public DpapiMasterKeyProvider(MasterKeyOptions options) : base(options)
+ {
+ }
+
+ ///
+ protected override byte[] ResolveKeyBytes()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ throw new PlatformNotSupportedException(
+ "DPAPI master-key protection is only supported on Windows.");
+ }
+
+ string? path = Options.FilePath;
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new MasterKeyUnavailableException("DPAPI master key file path is not configured.");
+ }
+
+ if (!File.Exists(path))
+ {
+ throw new MasterKeyUnavailableException($"DPAPI master key file '{path}' does not exist.");
+ }
+
+ byte[] blob = File.ReadAllBytes(path);
+ try
+ {
+ return ProtectedData.Unprotect(blob, optionalEntropy: null, DataProtectionScope.CurrentUser);
+ }
+ catch (CryptographicException ex)
+ {
+ throw new MasterKeyUnavailableException(
+ $"Failed to DPAPI-unprotect master key file '{path}': {ex.Message}");
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/EnvironmentMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/EnvironmentMasterKeyProvider.cs
new file mode 100644
index 0000000..e2b15ae
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/EnvironmentMasterKeyProvider.cs
@@ -0,0 +1,39 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.MasterKey;
+
+///
+/// Resolves the master key (KEK) from a base64-encoded environment variable named by
+/// . The variable is read fresh on every call so a rotated
+/// value takes effect immediately.
+///
+public sealed class EnvironmentMasterKeyProvider : MasterKeyProviderBase
+{
+ /// Creates the provider bound to .
+ /// The master-key configuration (uses ).
+ public EnvironmentMasterKeyProvider(MasterKeyOptions options) : base(options)
+ {
+ }
+
+ ///
+ protected override byte[] ResolveKeyBytes()
+ {
+ string envVarName = Options.EnvVarName;
+ string? value = Environment.GetEnvironmentVariable(envVarName);
+ if (string.IsNullOrEmpty(value))
+ {
+ throw new MasterKeyUnavailableException(
+ $"Master key environment variable '{envVarName}' is not set or is empty.");
+ }
+
+ try
+ {
+ return Convert.FromBase64String(value.Trim());
+ }
+ catch (FormatException ex)
+ {
+ throw new MasterKeyUnavailableException(
+ $"Master key environment variable '{envVarName}' is not valid base64: {ex.Message}");
+ }
+ }
+}
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
new file mode 100644
index 0000000..a317c81
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs
@@ -0,0 +1,58 @@
+using System.Text;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.MasterKey;
+
+///
+/// Resolves the master key (KEK) from the file at . The file
+/// may hold either the raw 32 key bytes, or base64 text of the key. The file is read fresh on every
+/// call so a rotated file takes effect immediately.
+///
+///
+/// Precedence: if the file's raw byte count is exactly 32, those bytes are the key. Otherwise the
+/// content is treated as text and base64-decoded (after trimming surrounding whitespace/newlines).
+/// The base-class length check then validates the decoded result.
+///
+public sealed class FileMasterKeyProvider : MasterKeyProviderBase
+{
+ /// Creates the provider bound to .
+ /// The master-key configuration (requires ).
+ public FileMasterKeyProvider(MasterKeyOptions options) : base(options)
+ {
+ }
+
+ ///
+ protected override byte[] ResolveKeyBytes()
+ {
+ string? path = Options.FilePath;
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new MasterKeyUnavailableException("Master key file path is not configured.");
+ }
+
+ if (!File.Exists(path))
+ {
+ throw new MasterKeyUnavailableException($"Master key file '{path}' does not exist.");
+ }
+
+ byte[] raw = File.ReadAllBytes(path);
+
+ // Raw 32-byte file: those bytes ARE the key.
+ if (raw.Length == KeySizeBytes)
+ {
+ return raw;
+ }
+
+ // Otherwise treat the content as base64 text.
+ string text = Encoding.UTF8.GetString(raw).Trim();
+ try
+ {
+ return Convert.FromBase64String(text);
+ }
+ catch (FormatException ex)
+ {
+ throw new MasterKeyUnavailableException(
+ $"Master key file '{path}' is neither raw 32 bytes nor valid base64: {ex.Message}");
+ }
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs
new file mode 100644
index 0000000..04b3282
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs
@@ -0,0 +1,50 @@
+namespace ZB.MOM.WW.Secrets.MasterKey;
+
+///
+/// Identifies where a resolves its 32-byte master key (KEK) from.
+///
+public enum MasterKeySource
+{
+ /// Read a base64-encoded key from an environment variable.
+ Environment,
+
+ /// Read the key from a file (raw 32 bytes, or base64/hex text).
+ File,
+
+ /// Unprotect a DPAPI-sealed blob file (Windows / ).
+ Dpapi,
+}
+
+///
+/// Configures which master-key (KEK) source a selects and how
+/// it resolves the key material.
+///
+///
+/// This is the minimal master-key config shape, kept here in the MasterKey folder so the providers do
+/// not take a forward dependency on the full application-level secrets options (composed later).
+///
+public sealed class MasterKeyOptions
+{
+ /// The source the master key is resolved from. Defaults to .
+ public MasterKeySource Source { get; set; } = MasterKeySource.Environment;
+
+ ///
+ /// The environment variable holding the base64-encoded 32-byte key, used when
+ /// is . Defaults to
+ /// ZB_SECRETS_MASTER_KEY.
+ ///
+ public string EnvVarName { get; set; } = "ZB_SECRETS_MASTER_KEY";
+
+ ///
+ /// The path to the key file, used when is
+ /// or .
+ ///
+ public string? FilePath { get; set; }
+
+ ///
+ /// An optional explicit key-encryption-key identifier. When non-empty it is used verbatim as the
+ /// provider's ; otherwise a deterministic,
+ /// non-secret id is derived from the key material.
+ ///
+ public string? KekId { get; set; }
+}
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
new file mode 100644
index 0000000..0f4e1d6
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs
@@ -0,0 +1,79 @@
+using System.Security.Cryptography;
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.MasterKey;
+
+///
+/// Shared base for the concrete implementations. Centralizes the
+/// 32-byte key-length validation and the non-secret derivation so every source
+/// (environment, file, DPAPI) fails closed identically and reports a stable identifier.
+///
+///
+/// The key is re-resolved on every call so that a rotated environment or
+/// 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.
+///
+public abstract class MasterKeyProviderBase : IMasterKeyProvider
+{
+ /// The required master-key length in bytes (AES-256).
+ protected const int KeySizeBytes = 32;
+
+ private readonly MasterKeyOptions _options;
+
+ /// Creates the base provider bound to the given .
+ /// The master-key configuration.
+ protected MasterKeyProviderBase(MasterKeyOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ /// The master-key configuration this provider was created with.
+ protected MasterKeyOptions Options => _options;
+
+ ///
+ public ReadOnlyMemory GetMasterKey() => ResolveValidatedKey();
+
+ ///
+ public string KekId
+ {
+ get
+ {
+ string? explicitId = _options.KekId;
+ if (!string.IsNullOrEmpty(explicitId))
+ {
+ return explicitId;
+ }
+
+ return DeriveKekId(ResolveValidatedKey());
+ }
+ }
+
+ ///
+ /// Resolves the raw key material from the concrete source. Implementations throw
+ /// 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).
+ protected abstract byte[] ResolveKeyBytes();
+
+ private byte[] ResolveValidatedKey()
+ {
+ byte[] key = ResolveKeyBytes();
+ if (key.Length != KeySizeBytes)
+ {
+ throw new MasterKeyUnavailableException(
+ $"Master key must be exactly {KeySizeBytes} bytes (AES-256) but was {key.Length} bytes.");
+ }
+
+ return key;
+ }
+
+ private static string DeriveKekId(byte[] key)
+ {
+ // Non-secret, deterministic id: a truncated SHA-256 of the key. The hash is one-way and
+ // truncated, so it identifies the key without revealing any usable key material.
+ string hex = Convert.ToHexString(SHA256.HashData(key));
+ return "sha256:" + hex[..12].ToLowerInvariant();
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs
new file mode 100644
index 0000000..1a2c199
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs
@@ -0,0 +1,36 @@
+using ZB.MOM.WW.Secrets.Abstractions;
+
+namespace ZB.MOM.WW.Secrets.MasterKey;
+
+///
+/// Selects and constructs the concrete matching a
+/// .
+///
+public static class MasterKeyProviderFactory
+{
+ ///
+ /// Creates the master-key provider for the configured .
+ ///
+ /// The master-key configuration.
+ /// The matching provider.
+ /// is .
+ /// The configured source is not a known value.
+ public static IMasterKeyProvider Create(MasterKeyOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ return options.Source switch
+ {
+ MasterKeySource.Environment => new EnvironmentMasterKeyProvider(options),
+ MasterKeySource.File => new FileMasterKeyProvider(options),
+ // Constructing the DPAPI provider does no Windows-only work; the platform guard lives in
+ // DpapiMasterKeyProvider.ResolveKeyBytes, which throws PlatformNotSupportedException off
+ // Windows. Suppress the type-level CA1416 for this construction only.
+#pragma warning disable CA1416
+ MasterKeySource.Dpapi => new DpapiMasterKeyProvider(options),
+#pragma warning restore CA1416
+ _ => throw new ArgumentOutOfRangeException(
+ nameof(options), options.Source, "Unknown master-key source."),
+ };
+ }
+}
diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj
index 2776ae5..450ec10 100644
--- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj
+++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj
@@ -6,6 +6,7 @@
+
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
index 097af48..b7f8eb1 100644
--- 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
@@ -25,6 +25,22 @@ public class AesGcmEnvelopeCipherTests
Assert.Equal("hunter2", decrypted);
}
+ [Fact]
+ public void TwoEncryptsProduceDifferentNonces()
+ {
+ var provider = new FakeMasterKeyProvider("k1");
+ var cipher = new AesGcmEnvelopeCipher(provider);
+ var name = new SecretName("sql/foo");
+
+ // Same plaintext + name encrypted twice must yield a fresh nonce (and therefore a distinct
+ // ciphertext) each time — the AES-GCM (key, nonce) uniqueness invariant.
+ StoredSecret first = cipher.Encrypt(name, "hunter2", SecretContentType.Text);
+ StoredSecret second = cipher.Encrypt(name, "hunter2", SecretContentType.Text);
+
+ Assert.False(first.Nonce.AsSpan().SequenceEqual(second.Nonce));
+ Assert.False(first.Ciphertext.AsSpan().SequenceEqual(second.Ciphertext));
+ }
+
[Fact]
public void TamperedCiphertextThrows()
{
diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs
new file mode 100644
index 0000000..4450466
--- /dev/null
+++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs
@@ -0,0 +1,229 @@
+using System.Security.Cryptography;
+using ZB.MOM.WW.Secrets.Abstractions;
+using ZB.MOM.WW.Secrets.MasterKey;
+
+namespace ZB.MOM.WW.Secrets.Tests.MasterKey;
+
+public class MasterKeyProviderTests
+{
+ // ---- Environment ----------------------------------------------------------------------
+
+ [Fact]
+ public void Environment_ReturnsDecodedKey()
+ {
+ byte[] key = RandomNumberGenerator.GetBytes(32);
+ string envVar = UniqueEnvVarName();
+ Environment.SetEnvironmentVariable(envVar, Convert.ToBase64String(key));
+ try
+ {
+ var provider = new EnvironmentMasterKeyProvider(new MasterKeyOptions { EnvVarName = envVar });
+
+ Assert.True(key.AsSpan().SequenceEqual(provider.GetMasterKey().Span));
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(envVar, null);
+ }
+ }
+
+ [Fact]
+ public void Environment_KekId_IsStableAcrossCalls()
+ {
+ byte[] key = RandomNumberGenerator.GetBytes(32);
+ string envVar = UniqueEnvVarName();
+ Environment.SetEnvironmentVariable(envVar, Convert.ToBase64String(key));
+ try
+ {
+ var provider = new EnvironmentMasterKeyProvider(new MasterKeyOptions { EnvVarName = envVar });
+
+ string first = provider.KekId;
+ string second = provider.KekId;
+
+ Assert.Equal(first, second);
+ Assert.StartsWith("sha256:", first, StringComparison.Ordinal);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(envVar, null);
+ }
+ }
+
+ [Fact]
+ public void Environment_ShortKey_Throws()
+ {
+ byte[] shortKey = RandomNumberGenerator.GetBytes(16);
+ string envVar = UniqueEnvVarName();
+ Environment.SetEnvironmentVariable(envVar, Convert.ToBase64String(shortKey));
+ try
+ {
+ var provider = new EnvironmentMasterKeyProvider(new MasterKeyOptions { EnvVarName = envVar });
+
+ Assert.Throws(() => provider.GetMasterKey());
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(envVar, null);
+ }
+ }
+
+ [Fact]
+ public void Environment_MissingVariable_Throws()
+ {
+ string envVar = UniqueEnvVarName();
+ Environment.SetEnvironmentVariable(envVar, null); // ensure absent
+ var provider = new EnvironmentMasterKeyProvider(new MasterKeyOptions { EnvVarName = envVar });
+
+ Assert.Throws(() => provider.GetMasterKey());
+ }
+
+ // ---- File -----------------------------------------------------------------------------
+
+ [Fact]
+ public void File_Raw32Bytes_ReturnsKey()
+ {
+ byte[] key = RandomNumberGenerator.GetBytes(32);
+ string path = Path.GetTempFileName();
+ File.WriteAllBytes(path, key);
+ try
+ {
+ var provider = new FileMasterKeyProvider(new MasterKeyOptions { FilePath = path });
+
+ Assert.True(key.AsSpan().SequenceEqual(provider.GetMasterKey().Span));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void File_Base64Text_ReturnsKey()
+ {
+ byte[] key = RandomNumberGenerator.GetBytes(32);
+ string path = Path.GetTempFileName();
+ File.WriteAllText(path, Convert.ToBase64String(key) + "\n");
+ try
+ {
+ var provider = new FileMasterKeyProvider(new MasterKeyOptions { FilePath = path });
+
+ Assert.True(key.AsSpan().SequenceEqual(provider.GetMasterKey().Span));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void File_TenBytes_Throws()
+ {
+ // 10 raw bytes: not 32, and not valid base64 text either → fail closed.
+ string path = Path.GetTempFileName();
+ File.WriteAllBytes(path, RandomNumberGenerator.GetBytes(10));
+ try
+ {
+ var provider = new FileMasterKeyProvider(new MasterKeyOptions { FilePath = path });
+
+ Assert.Throws(() => provider.GetMasterKey());
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void File_MissingFile_Throws()
+ {
+ var provider = new FileMasterKeyProvider(
+ new MasterKeyOptions { FilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")) });
+
+ Assert.Throws(() => provider.GetMasterKey());
+ }
+
+ // ---- KekId derivation -----------------------------------------------------------------
+
+ [Fact]
+ public void ExplicitKekId_IsUsedVerbatim()
+ {
+ byte[] key = RandomNumberGenerator.GetBytes(32);
+ string path = Path.GetTempFileName();
+ File.WriteAllBytes(path, key);
+ try
+ {
+ var provider = new FileMasterKeyProvider(
+ new MasterKeyOptions { FilePath = path, KekId = "prod-kek-2026" });
+
+ Assert.Equal("prod-kek-2026", provider.KekId);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void DerivedKekId_StartsWithSha256()
+ {
+ byte[] key = RandomNumberGenerator.GetBytes(32);
+ string path = Path.GetTempFileName();
+ File.WriteAllBytes(path, key);
+ try
+ {
+ var provider = new FileMasterKeyProvider(new MasterKeyOptions { FilePath = path });
+
+ Assert.StartsWith("sha256:", provider.KekId, StringComparison.Ordinal);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ // ---- Factory --------------------------------------------------------------------------
+
+ [Fact]
+ public void Factory_Environment_ReturnsEnvironmentProvider()
+ {
+ IMasterKeyProvider provider = MasterKeyProviderFactory.Create(
+ new MasterKeyOptions { Source = MasterKeySource.Environment });
+
+ Assert.IsType(provider);
+ }
+
+ [Fact]
+ public void Factory_File_ReturnsFileProvider()
+ {
+ IMasterKeyProvider provider = MasterKeyProviderFactory.Create(
+ new MasterKeyOptions { Source = MasterKeySource.File, FilePath = "unused" });
+
+ Assert.IsType(provider);
+ }
+
+ // ---- DPAPI (Windows-only) -------------------------------------------------------------
+
+ [SkippableFact]
+ public void Dpapi_RoundTripsOnWindows()
+ {
+ Skip.IfNot(OperatingSystem.IsWindows(), "DPAPI is only supported on Windows.");
+
+ byte[] key = RandomNumberGenerator.GetBytes(32);
+#pragma warning disable CA1416 // Whole body is guarded by Skip.IfNot(IsWindows) above.
+ byte[] blob = ProtectedData.Protect(key, optionalEntropy: null, DataProtectionScope.CurrentUser);
+ string path = Path.GetTempFileName();
+ File.WriteAllBytes(path, blob);
+ try
+ {
+ var provider = new DpapiMasterKeyProvider(new MasterKeyOptions { FilePath = path });
+
+ Assert.True(key.AsSpan().SequenceEqual(provider.GetMasterKey().Span));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+#pragma warning restore CA1416
+ }
+
+ private static string UniqueEnvVarName() => "ZB_SECRETS_TEST_" + Guid.NewGuid().ToString("N");
+}