From 1c13b4af997fe28cdb42b617c8108833982d6c83 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:43:10 -0400 Subject: [PATCH] feat(secrets): pluggable master-key providers (env/file/dpapi) + factory --- ZB.MOM.WW.Secrets/Directory.Packages.props | 3 + .../MasterKey/DpapiMasterKeyProvider.cs | 57 +++++ .../MasterKey/EnvironmentMasterKeyProvider.cs | 39 +++ .../MasterKey/FileMasterKeyProvider.cs | 58 +++++ .../MasterKey/MasterKeyOptions.cs | 50 ++++ .../MasterKey/MasterKeyProviderBase.cs | 79 ++++++ .../MasterKey/MasterKeyProviderFactory.cs | 36 +++ .../ZB.MOM.WW.Secrets.csproj | 1 + .../Crypto/AesGcmEnvelopeCipherTests.cs | 16 ++ .../MasterKey/MasterKeyProviderTests.cs | 229 ++++++++++++++++++ 10 files changed, 568 insertions(+) create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/DpapiMasterKeyProvider.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/EnvironmentMasterKeyProvider.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyOptions.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderBase.cs create mode 100644 ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs create mode 100644 ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs 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"); +}