feat(secrets): pluggable master-key providers (env/file/dpapi) + factory
This commit is contained in:
@@ -11,6 +11,9 @@
|
||||
<!-- Data -->
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
|
||||
<!-- Cryptography -->
|
||||
<PackageVersion Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
|
||||
|
||||
<!-- Extensions -->
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" 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>
|
||||
<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" />
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user