feat(secrets): pluggable master-key providers (env/file/dpapi) + factory

This commit is contained in:
Joseph Doherty
2026-07-15 16:43:10 -04:00
parent 01911508b9
commit 1c13b4af99
10 changed files with 568 additions and 0 deletions
@@ -11,6 +11,9 @@
<!-- Data --> <!-- Data -->
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<!-- Cryptography -->
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
<!-- Extensions --> <!-- Extensions -->
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.7" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" /> <PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
@@ -0,0 +1,57 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Resolves the master key (KEK) by unprotecting a DPAPI-sealed blob file at
/// <see cref="MasterKeyOptions.FilePath"/> under the current-user scope. Windows-only.
/// </summary>
/// <remarks>
/// The file must contain the ciphertext produced by
/// <see cref="ProtectedData.Protect(byte[], byte[], DataProtectionScope)"/> for the 32-byte key.
/// The blob is read and unprotected fresh on every call. On a non-Windows OS
/// <see cref="ResolveKeyBytes"/> throws <see cref="PlatformNotSupportedException"/>.
/// </remarks>
[SupportedOSPlatform("windows")]
public sealed class DpapiMasterKeyProvider : MasterKeyProviderBase
{
/// <summary>Creates the provider bound to <paramref name="options"/>.</summary>
/// <param name="options">The master-key configuration (requires <see cref="MasterKeyOptions.FilePath"/>).</param>
public DpapiMasterKeyProvider(MasterKeyOptions options) : base(options)
{
}
/// <inheritdoc />
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}");
}
}
}
@@ -0,0 +1,39 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Resolves the master key (KEK) from a base64-encoded environment variable named by
/// <see cref="MasterKeyOptions.EnvVarName"/>. The variable is read fresh on every call so a rotated
/// value takes effect immediately.
/// </summary>
public sealed class EnvironmentMasterKeyProvider : MasterKeyProviderBase
{
/// <summary>Creates the provider bound to <paramref name="options"/>.</summary>
/// <param name="options">The master-key configuration (uses <see cref="MasterKeyOptions.EnvVarName"/>).</param>
public EnvironmentMasterKeyProvider(MasterKeyOptions options) : base(options)
{
}
/// <inheritdoc />
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}");
}
}
}
@@ -0,0 +1,58 @@
using System.Text;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Resolves the master key (KEK) from the file at <see cref="MasterKeyOptions.FilePath"/>. 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class FileMasterKeyProvider : MasterKeyProviderBase
{
/// <summary>Creates the provider bound to <paramref name="options"/>.</summary>
/// <param name="options">The master-key configuration (requires <see cref="MasterKeyOptions.FilePath"/>).</param>
public FileMasterKeyProvider(MasterKeyOptions options) : base(options)
{
}
/// <inheritdoc />
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}");
}
}
}
@@ -0,0 +1,50 @@
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Identifies where a <see cref="MasterKeyProviderBase"/> resolves its 32-byte master key (KEK) from.
/// </summary>
public enum MasterKeySource
{
/// <summary>Read a base64-encoded key from an environment variable.</summary>
Environment,
/// <summary>Read the key from a file (raw 32 bytes, or base64/hex text).</summary>
File,
/// <summary>Unprotect a DPAPI-sealed blob file (Windows / <see cref="System.Security.Cryptography.DataProtectionScope.CurrentUser"/>).</summary>
Dpapi,
}
/// <summary>
/// Configures which master-key (KEK) source a <see cref="MasterKeyProviderFactory"/> selects and how
/// it resolves the key material.
/// </summary>
/// <remarks>
/// 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).
/// </remarks>
public sealed class MasterKeyOptions
{
/// <summary>The source the master key is resolved from. Defaults to <see cref="MasterKeySource.Environment"/>.</summary>
public MasterKeySource Source { get; set; } = MasterKeySource.Environment;
/// <summary>
/// The environment variable holding the base64-encoded 32-byte key, used when
/// <see cref="Source"/> is <see cref="MasterKeySource.Environment"/>. Defaults to
/// <c>ZB_SECRETS_MASTER_KEY</c>.
/// </summary>
public string EnvVarName { get; set; } = "ZB_SECRETS_MASTER_KEY";
/// <summary>
/// The path to the key file, used when <see cref="Source"/> is <see cref="MasterKeySource.File"/>
/// or <see cref="MasterKeySource.Dpapi"/>.
/// </summary>
public string? FilePath { get; set; }
/// <summary>
/// An optional explicit key-encryption-key identifier. When non-empty it is used verbatim as the
/// provider's <see cref="Abstractions.IMasterKeyProvider.KekId"/>; otherwise a deterministic,
/// non-secret id is derived from the key material.
/// </summary>
public string? KekId { get; set; }
}
@@ -0,0 +1,79 @@
using System.Security.Cryptography;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Shared base for the concrete <see cref="IMasterKeyProvider"/> implementations. Centralizes the
/// 32-byte key-length validation and the non-secret <see cref="KekId"/> derivation so every source
/// (environment, file, DPAPI) fails closed identically and reports a stable identifier.
/// </summary>
/// <remarks>
/// The key is re-resolved on every <see cref="GetMasterKey"/> call so that a rotated environment or
/// file value takes effect without recreating the provider. <see cref="KekId"/> 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.
/// </remarks>
public abstract class MasterKeyProviderBase : IMasterKeyProvider
{
/// <summary>The required master-key length in bytes (AES-256).</summary>
protected const int KeySizeBytes = 32;
private readonly MasterKeyOptions _options;
/// <summary>Creates the base provider bound to the given <paramref name="options"/>.</summary>
/// <param name="options">The master-key configuration.</param>
protected MasterKeyProviderBase(MasterKeyOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
/// <summary>The master-key configuration this provider was created with.</summary>
protected MasterKeyOptions Options => _options;
/// <inheritdoc />
public ReadOnlyMemory<byte> GetMasterKey() => ResolveValidatedKey();
/// <inheritdoc />
public string KekId
{
get
{
string? explicitId = _options.KekId;
if (!string.IsNullOrEmpty(explicitId))
{
return explicitId;
}
return DeriveKekId(ResolveValidatedKey());
}
}
/// <summary>
/// Resolves the raw key material from the concrete source. Implementations throw
/// <see cref="MasterKeyUnavailableException"/> when the source is missing or malformed; length
/// validation is applied by the base class.
/// </summary>
/// <returns>The resolved key bytes (any length; validated by the caller).</returns>
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();
}
}
@@ -0,0 +1,36 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.MasterKey;
/// <summary>
/// Selects and constructs the concrete <see cref="IMasterKeyProvider"/> matching a
/// <see cref="MasterKeyOptions.Source"/>.
/// </summary>
public static class MasterKeyProviderFactory
{
/// <summary>
/// Creates the master-key provider for the configured <see cref="MasterKeyOptions.Source"/>.
/// </summary>
/// <param name="options">The master-key configuration.</param>
/// <returns>The matching provider.</returns>
/// <exception cref="ArgumentNullException"><paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentOutOfRangeException">The configured source is not a known value.</exception>
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."),
};
}
}
@@ -6,6 +6,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" /> <PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" /> <PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
@@ -25,6 +25,22 @@ public class AesGcmEnvelopeCipherTests
Assert.Equal("hunter2", decrypted); 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] [Fact]
public void TamperedCiphertextThrows() public void TamperedCiphertextThrows()
{ {
@@ -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<MasterKeyUnavailableException>(() => 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<MasterKeyUnavailableException>(() => 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<MasterKeyUnavailableException>(() => 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<MasterKeyUnavailableException>(() => 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<EnvironmentMasterKeyProvider>(provider);
}
[Fact]
public void Factory_File_ReturnsFileProvider()
{
IMasterKeyProvider provider = MasterKeyProviderFactory.Create(
new MasterKeyOptions { Source = MasterKeySource.File, FilePath = "unused" });
Assert.IsType<FileMasterKeyProvider>(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");
}