diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs index ae9bbf8..3be253f 100644 --- a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs @@ -53,6 +53,11 @@ public sealed class SecretsSessionFactory // 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. + // + // The CLI deliberately treats a configured Secrets:SqlServer section as the authoritative target + // regardless of the app's replication topology: in hub-replication mode the SQL-Server hub is the + // shared source of truth, so editing it here is correct — the change converges to each node's local + // store via the sync sweep, rather than being written to one node's local SQLite and racing the hub. private static (ISecretStore Store, ISecretsStoreMigrator Migrator, string StoreKind) BuildStore( StoreTarget target, List warnings) { @@ -99,6 +104,16 @@ public sealed class SecretsSessionFactory return (provider, new AesGcmEnvelopeCipher(provider)); } + catch (ArgumentOutOfRangeException ex) + { + // MasterKeyProviderFactory.Create throws this when MasterKeySource is out of range — a + // malformed config value. This is operator-facing lockout tooling, so degrade rather than + // crash; name the invalid value so the operator can see what to fix. + warnings.Add( + $"Master-key source '{target.Secrets.MasterKey.Source}' is not a recognized value — " + + $"session is degraded (metadata only): {ex.Message}"); + return (null, null); + } catch (MasterKeyUnavailableException ex) { // Thrown by every provider (Environment/File/Dpapi) and the base length check when the key diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs index 1bacee1..6359cc6 100644 --- a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs @@ -95,6 +95,27 @@ public sealed class SecretsSessionFactoryTests : IDisposable Assert.Empty(list); } + [Fact] + public async Task Opens_degraded_session_when_master_key_source_is_out_of_range() + { + StoreTarget target = new TargetConfigReader().Manual(DbPath, new MasterKeyOptions + { + Source = (MasterKeySource)999, + }); + + SecretsSession session = + await new SecretsSessionFactory().OpenAsync(target, overrideKek: null, CancellationToken.None); + + Assert.False(session.KekAvailable); + Assert.Null(session.Cipher); + Assert.NotEmpty(session.Warnings); + Assert.Contains(session.Warnings, w => w.Contains("999", StringComparison.Ordinal)); + + // Store is still usable despite the malformed KEK source. + IReadOnlyList list = await session.Store.ListAsync(includeDeleted: false, CancellationToken.None); + Assert.Empty(list); + } + [Fact] public async Task Override_kek_upgrades_degraded_session() {