118 lines
5.4 KiB
C#
118 lines
5.4 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|