using Microsoft.Data.Sqlite; using Spectre.Console.Testing; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Cli.Interactive; using ZB.MOM.WW.Secrets.Cli.Interactive.Flows; using ZB.MOM.WW.Secrets.Crypto; using ZB.MOM.WW.Secrets.MasterKey; using ZB.MOM.WW.Secrets.Sqlite; namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive.Flows; /// /// Drives the lockout-recovery against a REAL temp-SQLite store and REAL /// AES-256-GCM cipher. KEK-A and KEK-B are two distinct 32-byte /// keys (distinct derived kek_ids): rows are sealed under one and the session opened under the /// other to reproduce the wrong-KEK lockout. Each flow renders to a scripted /// so the tests assert both the rendered and the resulting store state. /// The invariant is that the operator recovers a locked-out store from one screen — either by re-wrapping /// from the recovered old KEK or by re-setting the affected secrets — without any key or value ever /// reaching the terminal. /// public sealed class KekDoctorFlowTests : IAsyncLifetime, IDisposable { private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"zb-secrets-doctorflow-{Guid.NewGuid():N}.db"); private readonly SecretsSqliteConnectionFactory _factory; private readonly SqliteSecretStore _store; // Two distinct 32-byte keys → two distinct derived kek_ids (KEK-A / KEK-B). The base64 forms are // retained so a test can paste KEK-A at the prompt and assert it never echoes back to the terminal. private readonly string _kekABase64 = Convert.ToBase64String(FillKey(0xA1)); private readonly string _kekBBase64 = Convert.ToBase64String(FillKey(0xB2)); // A third, valid-format 32-byte key never used to seal a row — a "wrong but well-formed" paste. private readonly string _kekCBase64 = Convert.ToBase64String(FillKey(0xC3)); private readonly LiteralMasterKeyProvider _kekA; private readonly LiteralMasterKeyProvider _kekB; public KekDoctorFlowTests() { _factory = new SecretsSqliteConnectionFactory(_dbPath); _store = new SqliteSecretStore(_factory); _kekA = new LiteralMasterKeyProvider(_kekABase64); _kekB = new LiteralMasterKeyProvider(_kekBBase64); } public async Task InitializeAsync() => await new SqliteSecretsStoreMigrator(_factory).MigrateAsync(CancellationToken.None); public Task DisposeAsync() => Task.CompletedTask; private static byte[] FillKey(byte b) { byte[] key = new byte[32]; Array.Fill(key, b); return key; } private static TestConsole NewConsole() { var console = (TestConsole)new TestConsole().Interactive(); console.Profile.Width = 240; // wide enough that the summary lines never truncate a kek_id return console; } // A live session over the shared store, keyed by the supplied provider. private SecretsSession Session(IMasterKeyProvider kek) => new() { Target = new TargetConfigReader().Manual(_dbPath, new MasterKeyOptions()), Store = _store, Migrator = new SqliteSecretsStoreMigrator(_factory), MasterKey = kek, Cipher = new AesGcmEnvelopeCipher(kek), StoreKind = "sqlite", }; private async Task SeedAsync(string name, string value, IMasterKeyProvider kek) { var cipher = new AesGcmEnvelopeCipher(kek); StoredSecret row = cipher.Encrypt(new SecretName(name), value, SecretContentType.Text); await _store.UpsertAsync(row, CancellationToken.None); } private async Task GetAsync(string name) => (await _store.GetAsync(new SecretName(name), CancellationToken.None))!; [Fact] public async Task Healthy_store_renders_all_ok_summary() { await SeedAsync("svc/a", "value-a", _kekA); await SeedAsync("svc/b", "value-b", _kekA); TestConsole console = NewConsole(); await new KekDoctorFlow().RunAsync(console, Session(_kekA), CancellationToken.None); string output = console.Output; Assert.Contains(_kekA.KekId, output, StringComparison.Ordinal); // the session KEK is surfaced Assert.Contains("All rows open", output, StringComparison.Ordinal); // green all-clear Assert.DoesNotContain("Rewrap from old KEK", output, StringComparison.Ordinal); // no remedy menu Assert.True(new KekDoctorFlow().RequiresKek); Assert.Equal("KEK doctor (lockout recovery)", new KekDoctorFlow().Title); } [Fact] public async Task Wrong_kek_rows_offer_rewrap_and_rewrap_succeeds() { // Rows sealed under KEK-A; the session runs on KEK-B → both rows are wrong-KEK. await SeedAsync("svc/a", "value-a", _kekA); await SeedAsync("svc/b", "value-b", _kekA); SecretsSession session = Session(_kekB); TestConsole console = NewConsole(); console.Input.PushKey(ConsoleKey.Enter); // remedy menu: "Rewrap from old KEK" (index 0) console.Input.PushKey(ConsoleKey.DownArrow); // old-key source: skip "Environment variable" console.Input.PushKey(ConsoleKey.DownArrow); // skip "Key file" console.Input.PushKey(ConsoleKey.Enter); // choose "Paste base64 key" (index 2) console.Input.PushTextWithEnter(_kekABase64); // paste KEK-A material (masked) console.Input.PushTextWithEnter("y"); // confirm the rewrap (default is false) await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None); // Both rows now decrypt under the session KEK (B). var cipherB = new AesGcmEnvelopeCipher(_kekB); StoredSecret a = await GetAsync("svc/a"); StoredSecret b = await GetAsync("svc/b"); Assert.Equal(_kekB.KekId, a.KekId); Assert.Equal(_kekB.KekId, b.KekId); Assert.Equal("value-a", cipherB.Decrypt(a)); Assert.Equal("value-b", cipherB.Decrypt(b)); Assert.Contains("Re-wrapped", console.Output, StringComparison.Ordinal); Assert.Contains("2", console.Output, StringComparison.Ordinal); // the rewrap count Assert.DoesNotContain(_kekABase64, console.Output, StringComparison.Ordinal); // pasted key never echoed } [Fact] public async Task Rewrap_requires_explicit_confirmation() { await SeedAsync("svc/a", "value-a", _kekA); await SeedAsync("svc/b", "value-b", _kekA); SecretsSession session = Session(_kekB); TestConsole console = NewConsole(); console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK" console.Input.PushKey(ConsoleKey.DownArrow); console.Input.PushKey(ConsoleKey.DownArrow); console.Input.PushKey(ConsoleKey.Enter); // "Paste base64 key" console.Input.PushTextWithEnter(_kekABase64); console.Input.PushTextWithEnter("n"); // DECLINE the confirmation await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None); // Nothing was re-wrapped: both rows remain under KEK-A. Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); Assert.Equal(_kekA.KekId, (await GetAsync("svc/b")).KekId); } [Fact] public async Task Lost_kek_path_offers_reset_of_affected_secrets() { // Two wrong-KEK rows (sealed under KEK-A; session on KEK-B). Ordinal order: svc/a precedes svc/b. await SeedAsync("svc/a", "value-a", _kekA); await SeedAsync("svc/b", "value-b", _kekA); SecretsSession session = Session(_kekB); TestConsole console = NewConsole(); console.Input.PushKey(ConsoleKey.DownArrow); // remedy menu: move to console.Input.PushKey(ConsoleKey.Enter); // "Old KEK is lost — re-set affected secrets" (index 1) console.Input.PushTextWithEnter("y"); // svc/a: accept re-set console.Input.PushTextWithEnter("NEW-A-VALUE"); // masked new value console.Input.PushKey(ConsoleKey.Enter); // content type: Text (first choice) console.Input.PushTextWithEnter("n"); // svc/b: decline re-set await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None); // svc/a was re-set under the session KEK (B) with the new value. var cipherB = new AesGcmEnvelopeCipher(_kekB); StoredSecret a = await GetAsync("svc/a"); Assert.Equal(_kekB.KekId, a.KekId); Assert.Equal("NEW-A-VALUE", cipherB.Decrypt(a)); // svc/b was left untouched → still wrong-KEK (under KEK-A). Assert.Equal(_kekA.KekId, (await GetAsync("svc/b")).KekId); Assert.DoesNotContain("NEW-A-VALUE", console.Output, StringComparison.Ordinal); // value never echoed } [Fact] public async Task Malformed_pasted_old_key_renders_friendly_error_and_still_re_diagnoses() { await SeedAsync("svc/a", "value-a", _kekA); SecretsSession session = Session(_kekB); TestConsole console = NewConsole(); console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK" console.Input.PushKey(ConsoleKey.DownArrow); console.Input.PushKey(ConsoleKey.DownArrow); console.Input.PushKey(ConsoleKey.Enter); // "Paste base64 key" console.Input.PushTextWithEnter("!!!not-valid-base64!!!"); // malformed → ArgumentException await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None); Assert.Contains("Invalid old KEK", console.Output, StringComparison.Ordinal); // contained, not shell-escaped Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); // nothing changed } [Fact] public async Task Env_var_old_key_source_naming_unset_var_renders_friendly_error_and_still_re_diagnoses() { await SeedAsync("svc/a", "value-a", _kekA); SecretsSession session = Session(_kekB); TestConsole console = NewConsole(); console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK" console.Input.PushKey(ConsoleKey.Enter); // old-key source: "Environment variable" (index 0) // An env var guaranteed unset → KekId read lazily throws MasterKeyUnavailableException. console.Input.PushTextWithEnter($"ZB_TEST_UNSET_{Guid.NewGuid():N}"); await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None); Assert.Contains("Old KEK unavailable", console.Output, StringComparison.Ordinal); // contained Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); // nothing changed } [Fact] public async Task File_old_key_source_permission_denied_renders_friendly_error_and_still_re_diagnoses() { Skip.IfNot(!OperatingSystem.IsWindows(), "Unix permission bits are exercised via UnixFileMode."); await SeedAsync("svc/a", "value-a", _kekA); SecretsSession session = Session(_kekB); // A key file that exists (so FileMasterKeyProvider's File.Exists gate passes) but has no read // permission: File.ReadAllBytes inside ResolveKeyBytes throws UnauthorizedAccessException, which // FileMasterKeyProvider does not itself wrap into MasterKeyUnavailableException. string keyFilePath = Path.Combine(Path.GetTempPath(), $"zb-secrets-doctorflow-nokey-{Guid.NewGuid():N}"); await File.WriteAllBytesAsync(keyFilePath, new byte[32], CancellationToken.None); File.SetUnixFileMode(keyFilePath, UnixFileMode.None); try { TestConsole console = NewConsole(); console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK" console.Input.PushKey(ConsoleKey.DownArrow); // old-key source: skip "Environment variable" console.Input.PushKey(ConsoleKey.Enter); // choose "Key file" (index 1) console.Input.PushTextWithEnter(keyFilePath); await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None); Assert.Contains("Old KEK unavailable", console.Output, StringComparison.Ordinal); // contained Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); // nothing changed } finally { File.SetUnixFileMode(keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); File.Delete(keyFilePath); } } [Fact] public async Task Corrupt_row_is_offered_reset_as_the_only_remedy() { // Seed under the session KEK, then tamper the DEK wrap tag: kek_id still matches the session KEK, // but the decrypt probe fails closed → the row diagnoses Corrupt (not WrongKek). await SeedAsync("svc/corrupt", "original", _kekA); StoredSecret seeded = await GetAsync("svc/corrupt"); byte[] tag = (byte[])seeded.WrapTag.Clone(); tag[0] ^= 0xFF; await _store.UpsertAsync(seeded with { WrapTag = tag }, CancellationToken.None); SecretsSession session = Session(_kekA); // same KEK — so the row is Corrupt, not WrongKek TestConsole console = NewConsole(); console.Input.PushKey(ConsoleKey.DownArrow); // remedy menu: move to console.Input.PushKey(ConsoleKey.Enter); // "Old KEK is lost — re-set affected secrets" console.Input.PushTextWithEnter("y"); // accept re-set of the corrupt row console.Input.PushTextWithEnter("RESEEDED-VALUE"); // masked new value console.Input.PushKey(ConsoleKey.Enter); // content type: Text await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None); Assert.Contains("corrupt", console.Output, StringComparison.Ordinal); // the diagnosis surfaced it Assert.Contains("only remedy", console.Output, StringComparison.Ordinal); // the re-set note // The corrupt row was re-seeded and now decrypts cleanly under the session KEK. var cipherA = new AesGcmEnvelopeCipher(_kekA); StoredSecret after = await GetAsync("svc/corrupt"); Assert.Equal("RESEEDED-VALUE", cipherA.Decrypt(after)); Assert.DoesNotContain("RESEEDED-VALUE", console.Output, StringComparison.Ordinal); // never echoed } [Fact] public async Task Wrong_but_wellformed_old_key_fails_the_rewrap_and_still_re_diagnoses() { // Rows under KEK-A; session on KEK-B. The operator pastes KEK-C — valid format, but neither the // old (A) nor the session (B) KEK → RewrapAllAsync aborts with SecretDecryptionException. await SeedAsync("svc/a", "value-a", _kekA); await SeedAsync("svc/b", "value-b", _kekA); SecretsSession session = Session(_kekB); TestConsole console = NewConsole(); console.Input.PushKey(ConsoleKey.Enter); // "Rewrap from old KEK" console.Input.PushKey(ConsoleKey.DownArrow); console.Input.PushKey(ConsoleKey.DownArrow); console.Input.PushKey(ConsoleKey.Enter); // "Paste base64 key" console.Input.PushTextWithEnter(_kekCBase64); // valid format, wrong key console.Input.PushTextWithEnter("y"); // confirm the rewrap await new KekDoctorFlow().RunAsync(console, session, CancellationToken.None); Assert.Contains("Rewrap failed", console.Output, StringComparison.Ordinal); // the SecretDecryptionException path Assert.Contains("Re-diagnosis", console.Output, StringComparison.Ordinal); // closing summary still runs // The pass aborted on the first row → both rows remain under KEK-A. Assert.Equal(_kekA.KekId, (await GetAsync("svc/a")).KekId); Assert.Equal(_kekA.KekId, (await GetAsync("svc/b")).KekId); } public void Dispose() { SqliteConnection.ClearAllPools(); foreach (string path in new[] { _dbPath, _dbPath + "-wal", _dbPath + "-shm" }) { try { if (File.Exists(path)) { File.Delete(path); } } catch (IOException) { // Best-effort temp cleanup. } } } }