feat(secrets-cli): per-target session factory with degraded-KEK mode + literal KEK provider
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.MasterKey;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An <see cref="IMasterKeyProvider"/> over master-key material an operator pastes at the console —
|
||||||
|
/// the interactive lockout-recovery path when the configured KEK source (an environment variable or a
|
||||||
|
/// file) is not visible in the CLI's own shell.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Subclasses <see cref="MasterKeyProviderBase"/> so the non-secret <see cref="IMasterKeyProvider.KekId"/>
|
||||||
|
/// is derived by the exact same scheme every other provider uses. That interop is load-bearing: a key
|
||||||
|
/// entered here must resolve to the same <c>kek_id</c> the service wrote rows under, or every decrypt
|
||||||
|
/// and re-wrap fails closed on a KEK mismatch. Pinned by
|
||||||
|
/// <c>LiteralMasterKeyProviderTests.Derives_same_kekid_as_environment_provider_for_same_material</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LiteralMasterKeyProvider : MasterKeyProviderBase
|
||||||
|
{
|
||||||
|
private readonly byte[] _key;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the provider from base64-encoded 32-byte key material, validated eagerly so a bad paste
|
||||||
|
/// is rejected at entry rather than on the first decrypt.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="base64Key">The base64-encoded 32-byte master key (KEK).</param>
|
||||||
|
/// <param name="kekId">
|
||||||
|
/// An optional explicit key id used verbatim as <see cref="IMasterKeyProvider.KekId"/>; when
|
||||||
|
/// <see langword="null"/> or empty the id is derived from the key material, matching the other
|
||||||
|
/// providers.
|
||||||
|
/// </param>
|
||||||
|
/// <exception cref="ArgumentException">
|
||||||
|
/// <paramref name="base64Key"/> is null/whitespace, is not valid base64, or does not decode to
|
||||||
|
/// exactly 32 bytes.
|
||||||
|
/// </exception>
|
||||||
|
public LiteralMasterKeyProvider(string base64Key, string? kekId = null)
|
||||||
|
: base(new MasterKeyOptions { KekId = kekId })
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(base64Key);
|
||||||
|
|
||||||
|
byte[] decoded;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
decoded = Convert.FromBase64String(base64Key.Trim());
|
||||||
|
}
|
||||||
|
catch (FormatException ex)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Master key is not valid base64: {ex.Message}", nameof(base64Key), ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded.Length != KeySizeBytes)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"Master key must be exactly {KeySizeBytes} bytes (AES-256) but decoded to {decoded.Length} bytes.",
|
||||||
|
nameof(base64Key));
|
||||||
|
}
|
||||||
|
|
||||||
|
_key = decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override byte[] ResolveKeyBytes() => (byte[])_key.Clone();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>An open store session for one target. Degraded when no KEK is available (metadata ops only).</summary>
|
||||||
|
public sealed class SecretsSession
|
||||||
|
{
|
||||||
|
/// <summary>The target this session operates on.</summary>
|
||||||
|
public required StoreTarget Target { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The store backing this session (SQLite or SQL Server).</summary>
|
||||||
|
public required ISecretStore Store { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The migrator matching <see cref="Store"/>; already run once when the session was opened.</summary>
|
||||||
|
public required ISecretsStoreMigrator Migrator { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The resolved master-key provider, or <see langword="null"/> when the session is degraded.</summary>
|
||||||
|
public IMasterKeyProvider? MasterKey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The envelope cipher, or <see langword="null"/> when the session is degraded (no KEK).</summary>
|
||||||
|
public ISecretCipher? Cipher { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The concrete store kind: <c>"sqlite"</c> or <c>"sqlserver"</c>.</summary>
|
||||||
|
public required string StoreKind { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Non-fatal warnings raised while opening (KEK unavailable, store fall-back, and so on).</summary>
|
||||||
|
public IReadOnlyList<string> Warnings { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether a KEK (and therefore a cipher) is available — <see langword="false"/> in degraded mode.</summary>
|
||||||
|
public bool KekAvailable => Cipher is not null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using ZB.MOM.WW.Secrets.Abstractions;
|
||||||
|
using ZB.MOM.WW.Secrets.Crypto;
|
||||||
|
using ZB.MOM.WW.Secrets.MasterKey;
|
||||||
|
using ZB.MOM.WW.Secrets.Replicator.SqlServer;
|
||||||
|
using ZB.MOM.WW.Secrets.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a <see cref="SecretsSession"/> for a target by constructing the same store stack the app's DI
|
||||||
|
/// extensions build — but directly, without a host, and (unlike the app) tolerant of a missing KEK:
|
||||||
|
/// resolution failure yields a DEGRADED session the shell can later upgrade with an operator-supplied
|
||||||
|
/// key, rather than throwing and leaving the operator unable to even list what is in the store.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SecretsSessionFactory
|
||||||
|
{
|
||||||
|
/// <summary>Marker for an unresolved <c>${secret:}</c> reference embedded in a connection string.</summary>
|
||||||
|
private const string SecretRefMarker = "${secret:";
|
||||||
|
|
||||||
|
/// <summary>Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw.</summary>
|
||||||
|
/// <param name="target">The resolved store target.</param>
|
||||||
|
/// <param name="overrideKek">
|
||||||
|
/// An operator-supplied KEK that pre-empts the target's configured source, or <see langword="null"/>
|
||||||
|
/// to use the configured provider.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ct">A token to cancel the schema migration.</param>
|
||||||
|
/// <returns>An open session — full when a KEK resolves, degraded otherwise.</returns>
|
||||||
|
public async Task<SecretsSession> OpenAsync(
|
||||||
|
StoreTarget target, IMasterKeyProvider? overrideKek, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(target);
|
||||||
|
|
||||||
|
List<string> warnings = [];
|
||||||
|
|
||||||
|
(ISecretStore store, ISecretsStoreMigrator migrator, string storeKind) = BuildStore(target, warnings);
|
||||||
|
(IMasterKeyProvider? masterKey, ISecretCipher? cipher) = ResolveKek(target, overrideKek, warnings);
|
||||||
|
|
||||||
|
// Migrate regardless of KEK state: metadata/list ops must work in a degraded session.
|
||||||
|
await migrator.MigrateAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new SecretsSession
|
||||||
|
{
|
||||||
|
Target = target,
|
||||||
|
Store = store,
|
||||||
|
Migrator = migrator,
|
||||||
|
MasterKey = masterKey,
|
||||||
|
Cipher = cipher,
|
||||||
|
StoreKind = storeKind,
|
||||||
|
Warnings = warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors the DI extensions' store selection: a configured SQL-Server hub wins, EXCEPT when its
|
||||||
|
// connection string is an unresolved '${secret:}' reference (the app resolves that from its own
|
||||||
|
// bootstrapped store at startup; the CLI has no such resolver), in which case fall back to SQLite.
|
||||||
|
private static (ISecretStore Store, ISecretsStoreMigrator Migrator, string StoreKind) BuildStore(
|
||||||
|
StoreTarget target, List<string> warnings)
|
||||||
|
{
|
||||||
|
if (target.SqlServer is { } sql && !string.IsNullOrEmpty(sql.ConnectionString))
|
||||||
|
{
|
||||||
|
if (sql.ConnectionString.Contains(SecretRefMarker, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
warnings.Add(
|
||||||
|
"SQL-Server connection string is an unresolved '${secret:}' reference the console " +
|
||||||
|
"cannot resolve; falling back to the local SQLite store.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var sqlFactory = new SecretsSqlServerConnectionFactory(sql);
|
||||||
|
return (
|
||||||
|
new SqlServerSecretStore(sqlFactory),
|
||||||
|
new SqlServerSecretsStoreMigrator(sqlFactory),
|
||||||
|
"sqlserver");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sqliteFactory = new SecretsSqliteConnectionFactory(target.Secrets.SqlitePath);
|
||||||
|
return (
|
||||||
|
new SqliteSecretStore(sqliteFactory),
|
||||||
|
new SqliteSecretsStoreMigrator(sqliteFactory),
|
||||||
|
"sqlite");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves the KEK (override, else the configured provider) and forces material resolution now. A
|
||||||
|
// documented resolution failure degrades the session here instead of throwing on the first seal.
|
||||||
|
private static (IMasterKeyProvider? MasterKey, ISecretCipher? Cipher) ResolveKek(
|
||||||
|
StoreTarget target, IMasterKeyProvider? overrideKek, List<string> warnings)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IMasterKeyProvider provider =
|
||||||
|
overrideKek ?? MasterKeyProviderFactory.Create(target.Secrets.MasterKey);
|
||||||
|
|
||||||
|
// GetMasterKey (not KekId) is the probe: KekId short-circuits when an explicit id is
|
||||||
|
// configured (MasterKeyProviderBase.KekId) and so would not touch the key source, whereas
|
||||||
|
// GetMasterKey always resolves and length-validates the material — exactly what must
|
||||||
|
// succeed for the cipher to work.
|
||||||
|
_ = provider.GetMasterKey();
|
||||||
|
|
||||||
|
return (provider, new AesGcmEnvelopeCipher(provider));
|
||||||
|
}
|
||||||
|
catch (MasterKeyUnavailableException ex)
|
||||||
|
{
|
||||||
|
// Thrown by every provider (Environment/File/Dpapi) and the base length check when the key
|
||||||
|
// source is missing, empty, malformed, or the wrong length.
|
||||||
|
warnings.Add($"Master key unavailable — session is degraded (metadata only): {ex.Message}");
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
catch (PlatformNotSupportedException ex)
|
||||||
|
{
|
||||||
|
// Thrown by DpapiMasterKeyProvider.ResolveKeyBytes off Windows.
|
||||||
|
warnings.Add(
|
||||||
|
$"Master key source is not supported on this platform — session is degraded (metadata only): {ex.Message}");
|
||||||
|
return (null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user