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; /// /// Opens a 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. /// public sealed class SecretsSessionFactory { /// Marker for an unresolved ${secret:} reference embedded in a connection string. private const string SecretRefMarker = "${secret:"; /// Builds the store stack for a target. KEK resolution failure yields a DEGRADED session, never a throw. /// The resolved store target. /// /// An operator-supplied KEK that pre-empts the target's configured source, or /// to use the configured provider. /// /// A token to cancel the schema migration. /// An open session — full when a KEK resolves, degraded otherwise. public async Task OpenAsync( StoreTarget target, IMasterKeyProvider? overrideKek, CancellationToken ct) { ArgumentNullException.ThrowIfNull(target); List 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 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 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); } } }