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,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);
}
}
}