feat(secrets): pluggable master-key providers (env/file/dpapi) + factory
This commit is contained in:
@@ -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