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