feat(secrets-cli): per-target session factory with degraded-KEK mode + literal KEK provider

This commit is contained in:
Joseph Doherty
2026-07-19 09:01:46 -04:00
parent b8c6c7d432
commit 83af2b2eaf
5 changed files with 422 additions and 0 deletions
@@ -0,0 +1,74 @@
using System.Security.Cryptography;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.MasterKey;
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
/// <summary>
/// Pins <see cref="LiteralMasterKeyProvider"/> — the operator-pasted-key provider used to recover a
/// locked-out session — to the family's key contract: 32-byte enforcement and, critically, a
/// <see cref="IMasterKeyProvider.KekId"/> derived identically to the configured providers so a key
/// entered by hand can decrypt (and re-wrap) rows another provider kind sealed.
/// </summary>
public sealed class LiteralMasterKeyProviderTests
{
private static string NewKeyBase64()
{
byte[] key = new byte[32];
RandomNumberGenerator.Fill(key);
return Convert.ToBase64String(key);
}
[Fact]
public void Accepts_base64_32_byte_key()
{
string b64 = NewKeyBase64();
var sut = new LiteralMasterKeyProvider(b64);
Assert.Equal(32, sut.GetMasterKey().Length);
Assert.StartsWith("sha256:", sut.KekId);
}
[Fact]
public void Rejects_wrong_length()
{
string sixteenBytes = Convert.ToBase64String(new byte[16]);
Assert.Throws<ArgumentException>(() => new LiteralMasterKeyProvider(sixteenBytes));
}
[Fact]
public void Derives_same_kekid_as_environment_provider_for_same_material()
{
byte[] key = new byte[32];
RandomNumberGenerator.Fill(key);
string b64 = Convert.ToBase64String(key);
string envVar = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
Environment.SetEnvironmentVariable(envVar, b64);
try
{
IMasterKeyProvider environment = MasterKeyProviderFactory.Create(new MasterKeyOptions
{
Source = MasterKeySource.Environment,
EnvVarName = envVar,
});
var literal = new LiteralMasterKeyProvider(b64);
Assert.Equal(environment.KekId, literal.KekId);
}
finally
{
Environment.SetEnvironmentVariable(envVar, null);
}
}
[Fact]
public void Explicit_kekid_wins()
{
var sut = new LiteralMasterKeyProvider(NewKeyBase64(), "kek:operator-override");
Assert.Equal("kek:operator-override", sut.KekId);
}
}
@@ -0,0 +1,136 @@
using System.Security.Cryptography;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.MasterKey;
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;
/// <summary>
/// Exercises <see cref="SecretsSessionFactory"/> against real temp-dir SQLite targets: the happy path
/// (env KEK present → full session + seal/upsert/get/decrypt round-trip), the lockout-recovery path
/// (KEK unresolvable → degraded session that still lists metadata), the operator-override upgrade, and
/// the SQL-Server-with-unresolved-secret-ref fall-back to the local store.
/// </summary>
public sealed class SecretsSessionFactoryTests : IDisposable
{
private readonly string _dir =
Path.Combine(Path.GetTempPath(), $"zb-secrets-session-{Guid.NewGuid():N}");
private readonly List<string> _envVars = [];
private string DbPath => Path.Combine(_dir, "secrets.db");
public SecretsSessionFactoryTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
foreach (string name in _envVars)
{
Environment.SetEnvironmentVariable(name, null);
}
if (Directory.Exists(_dir))
{
try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
}
}
private string SetTempKeyEnv()
{
byte[] key = new byte[32];
RandomNumberGenerator.Fill(key);
string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
Environment.SetEnvironmentVariable(name, Convert.ToBase64String(key));
_envVars.Add(name);
return name;
}
private static MasterKeyOptions UnsetEnvKey() => new()
{
Source = MasterKeySource.Environment,
EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
};
[Fact]
public async Task Opens_full_session_when_env_kek_present()
{
string envVar = SetTempKeyEnv();
StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions
{
Source = MasterKeySource.Environment,
EnvVarName = envVar,
});
SecretsSession session =
await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
Assert.True(session.KekAvailable);
Assert.Equal("sqlite", session.StoreKind);
Assert.NotNull(session.Cipher);
var name = new SecretName("test/roundtrip");
StoredSecret row = session.Cipher!.Encrypt(name, "hunter2", SecretContentType.Text);
await session.Store.UpsertAsync(row, CancellationToken.None);
StoredSecret? fetched = await session.Store.GetAsync(name, CancellationToken.None);
Assert.NotNull(fetched);
Assert.Equal("hunter2", session.Cipher!.Decrypt(fetched!));
}
[Fact]
public async Task Opens_degraded_session_when_env_var_unset()
{
StoreTarget target = new TargetConfigReader().Manual(DbPath, UnsetEnvKey());
SecretsSession session =
await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
Assert.False(session.KekAvailable);
Assert.Null(session.Cipher);
Assert.Null(session.MasterKey);
Assert.NotEmpty(session.Warnings);
// Store is still usable for metadata ops even without a KEK.
IReadOnlyList<SecretMetadata> list = await session.Store.ListAsync(includeDeleted: false, CancellationToken.None);
Assert.Empty(list);
}
[Fact]
public async Task Override_kek_upgrades_degraded_session()
{
StoreTarget target = new TargetConfigReader().Manual(DbPath, UnsetEnvKey());
byte[] key = new byte[32];
RandomNumberGenerator.Fill(key);
var overrideKek = new LiteralMasterKeyProvider(Convert.ToBase64String(key));
SecretsSession session =
await new SecretsSessionFactory().OpenAsync(target, overrideKek, CancellationToken.None);
Assert.True(session.KekAvailable);
Assert.NotNull(session.Cipher);
Assert.Same(overrideKek, session.MasterKey);
}
[Fact]
public async Task SqlServer_target_with_unresolved_secret_ref_connstr_falls_back_to_sqlite_with_warning()
{
string envVar = SetTempKeyEnv();
SecretsOptions secrets = new()
{
SqlitePath = DbPath,
MasterKey = new MasterKeyOptions { Source = MasterKeySource.Environment, EnvVarName = envVar },
};
SqlServerSecretsOptions sqlServer = new()
{
ConnectionString = "Server=hub;Database=secrets;User Id=app;Password=${secret:sql/hub-pw}",
};
StoreTarget target = new(
"sql-hub", AppSettingsPath: null, secrets, sqlServer, new ConfigurationBuilder().Build());
SecretsSession session =
await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None);
Assert.Equal("sqlite", session.StoreKind);
Assert.Contains(session.Warnings, w => w.Contains("SQLite", StringComparison.OrdinalIgnoreCase));
}
}