diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs new file mode 100644 index 0000000..7a81a5c --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/LiteralMasterKeyProvider.cs @@ -0,0 +1,64 @@ +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.MasterKey; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// +/// An 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. +/// +/// +/// Subclasses so the non-secret +/// 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 kek_id the service wrote rows under, or every decrypt +/// and re-wrap fails closed on a KEK mismatch. Pinned by +/// LiteralMasterKeyProviderTests.Derives_same_kekid_as_environment_provider_for_same_material. +/// +public sealed class LiteralMasterKeyProvider : MasterKeyProviderBase +{ + private readonly byte[] _key; + + /// + /// 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. + /// + /// The base64-encoded 32-byte master key (KEK). + /// + /// An optional explicit key id used verbatim as ; when + /// or empty the id is derived from the key material, matching the other + /// providers. + /// + /// + /// is null/whitespace, is not valid base64, or does not decode to + /// exactly 32 bytes. + /// + 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; + } + + /// + protected override byte[] ResolveKeyBytes() => (byte[])_key.Clone(); +} diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs new file mode 100644 index 0000000..e20db2f --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSession.cs @@ -0,0 +1,31 @@ +using ZB.MOM.WW.Secrets.Abstractions; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// An open store session for one target. Degraded when no KEK is available (metadata ops only). +public sealed class SecretsSession +{ + /// The target this session operates on. + public required StoreTarget Target { get; init; } + + /// The store backing this session (SQLite or SQL Server). + public required ISecretStore Store { get; init; } + + /// The migrator matching ; already run once when the session was opened. + public required ISecretsStoreMigrator Migrator { get; init; } + + /// The resolved master-key provider, or when the session is degraded. + public IMasterKeyProvider? MasterKey { get; init; } + + /// The envelope cipher, or when the session is degraded (no KEK). + public ISecretCipher? Cipher { get; init; } + + /// The concrete store kind: "sqlite" or "sqlserver". + public required string StoreKind { get; init; } + + /// Non-fatal warnings raised while opening (KEK unavailable, store fall-back, and so on). + public IReadOnlyList Warnings { get; init; } = []; + + /// Whether a KEK (and therefore a cipher) is available — in degraded mode. + public bool KekAvailable => Cipher is not null; +} 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 new file mode 100644 index 0000000..ae9bbf8 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/SecretsSessionFactory.cs @@ -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; + +/// +/// 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); + } + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/LiteralMasterKeyProviderTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/LiteralMasterKeyProviderTests.cs new file mode 100644 index 0000000..e718344 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/LiteralMasterKeyProviderTests.cs @@ -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; + +/// +/// Pins — the operator-pasted-key provider used to recover a +/// locked-out session — to the family's key contract: 32-byte enforcement and, critically, a +/// derived identically to the configured providers so a key +/// entered by hand can decrypt (and re-wrap) rows another provider kind sealed. +/// +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(() => 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); + } +} 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 new file mode 100644 index 0000000..1bacee1 --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/SecretsSessionFactoryTests.cs @@ -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; + +/// +/// Exercises 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. +/// +public sealed class SecretsSessionFactoryTests : IDisposable +{ + private readonly string _dir = + Path.Combine(Path.GetTempPath(), $"zb-secrets-session-{Guid.NewGuid():N}"); + private readonly List _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 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)); + } +}