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
@@ -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>
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />