diff --git a/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs new file mode 100644 index 0000000..6e3a017 --- /dev/null +++ b/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/Interactive/KekDoctor.cs @@ -0,0 +1,157 @@ +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Rotation; + +namespace ZB.MOM.WW.Secrets.Cli.Interactive; + +/// The per-row verdict of a KEK diagnosis. +public enum RowKekStatus +{ + /// The session KEK matches the row and successfully unwraps it — the row is healthy. + Ok, + + /// The row is wrapped under a different KEK than the session's — the session cannot open it. + WrongKek, + + /// + /// The row's kek_id matches the session KEK, yet the DEK unwrap / decrypt fails closed — + /// the wrap envelope or sealed body is damaged. + /// + Corrupt, +} + +/// One row's KEK verdict: its name, status, and the kek_id it is actually wrapped under. +/// The secret's normalized name. +/// Whether the session KEK opens the row (). +/// +/// The kek_id stamped on the row — the key an operator must hunt down when the status is +/// . +/// +public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string RowKekId); + +/// +/// The full diagnosis: the session's KEK id and the per-row verdicts (ordered by name), including +/// tombstoned rows since they still carry a KEK-wrapped DEK and block a rewrap-all. +/// +/// The kek_id of the KEK the session is currently using. +/// The per-row verdicts, ordered by secret name. +public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList Rows) +{ + /// Whether every diagnosed row opens cleanly under the session KEK. + public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok); +} + +/// +/// Lockout triage: verifies the session KEK actually opens every stored row and drives the rewrap +/// remedy. An operator whose app can no longer decrypt its secrets runs the doctor to learn, per row, +/// which KEK each row is wrapped under and whether the session KEK opens it, then remedies the +/// wrong-KEK rows with a guided rewrap-all onto the session KEK. +/// +/// +/// The store surface makes a full decrypt probe possible even for tombstoned rows: +/// enumerates all rows' metadata (including tombstones with +/// includeDeleted: true) and returns the full ciphertext +/// row for any name including a tombstoned one, so every row — live or dead — is probed the +/// same way. Plaintext produced by the probe is discarded immediately and never surfaced. +/// +public sealed class KekDoctor +{ + /// + /// Diagnoses every stored row (including tombstones) against the session KEK: a row whose + /// kek_id differs from the session KEK is reported + /// with no decrypt attempted; a row whose kek_id matches is decrypt-probed and reported + /// or, if the unwrap fails closed, . + /// + /// The open session; must not be degraded (a KEK is required). + /// A token to cancel the diagnosis. + /// A with the per-row verdicts, ordered by name. + /// is . + /// + /// The session is degraded (no KEK / cipher). The operator must re-open the session supplying a + /// master key before the doctor can probe rows. + /// + public async Task DiagnoseAsync(SecretsSession session, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(session); + + if (session.MasterKey is null || session.Cipher is null) + { + throw new InvalidOperationException( + "The session is degraded: no master key (KEK) is available, so rows cannot be probed. " + + "Re-open the session and supply the key material (for example, paste it at the prompt)."); + } + + string sessionKekId = session.MasterKey.KekId; + + // ListAsync gives metadata only; GetAsync fetches the full ciphertext row (tombstones included) + // for the decrypt probe. Enumerate ALL rows so tombstones — which still block a rewrap-all — show. + IReadOnlyList all = + await session.Store.ListAsync(includeDeleted: true, ct).ConfigureAwait(false); + + List rows = []; + foreach (SecretMetadata meta in all.OrderBy(m => m.Name.Value, StringComparer.Ordinal)) + { + ct.ThrowIfCancellationRequested(); + + // A row under a different KEK cannot be opened by this session — report it (with its own + // kek_id so the operator knows which key to hunt) WITHOUT attempting a doomed decrypt. + if (!string.Equals(meta.KekId, sessionKekId, StringComparison.Ordinal)) + { + rows.Add(new KekDiagnosis(meta.Name.Value, RowKekStatus.WrongKek, meta.KekId)); + continue; + } + + StoredSecret? row = await session.Store.GetAsync(meta.Name, ct).ConfigureAwait(false); + if (row is null) + { + // Vanished between the list and the fetch (a concurrent hard-absence) — nothing to probe. + continue; + } + + RowKekStatus status; + try + { + // The kek_id matches: probe the actual unwrap/decrypt. Plaintext is discarded at once. + _ = session.Cipher.Decrypt(row); + status = RowKekStatus.Ok; + } + catch (SecretDecryptionException) + { + status = RowKekStatus.Corrupt; + } + + rows.Add(new KekDiagnosis(meta.Name.Value, status, row.KekId)); + } + + return new KekDoctorReport(sessionKekId, rows); + } + + /// + /// Remedies wrong-KEK rows by re-wrapping every row from onto the + /// session's KEK, delegating to (idempotent, and + /// fail-closed on rows wrapped by neither the old nor the session KEK). + /// + /// The open session; must not be degraded (its KEK is the rewrap target). + /// The provider for the KEK the wrong-KEK rows are currently wrapped under. + /// A token to cancel the pass (already-persisted re-wraps are retained). + /// A summarizing the pass (no secret material). + /// + /// or is . + /// + /// The session is degraded (no KEK / cipher). + public Task RewrapAllAsync( + SecretsSession session, IMasterKeyProvider oldKek, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(oldKek); + + if (session.MasterKey is null || session.Cipher is null) + { + throw new InvalidOperationException( + "The session is degraded: no master key (KEK) is available to re-wrap onto. " + + "Re-open the session and supply the key material before running the rewrap remedy."); + } + + return new KekRotationService(session.Store, session.Cipher) + .RewrapAllAsync(oldKek, session.MasterKey, ct); + } +} diff --git a/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs new file mode 100644 index 0000000..c2f5b4d --- /dev/null +++ b/ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/Cli/Interactive/KekDoctorTests.cs @@ -0,0 +1,194 @@ +using Microsoft.Data.Sqlite; +using ZB.MOM.WW.Secrets.Abstractions; +using ZB.MOM.WW.Secrets.Cli.Interactive; +using ZB.MOM.WW.Secrets.Crypto; +using ZB.MOM.WW.Secrets.MasterKey; +using ZB.MOM.WW.Secrets.Rotation; +using ZB.MOM.WW.Secrets.Sqlite; + +namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive; + +/// +/// Exercises against the REAL SQLite store and REAL AES-256-GCM cipher — the +/// lockout-triage path an operator runs to learn, per row, which KEK it is wrapped under and whether +/// the session KEK actually opens it, then remedies via a guided rewrap-all. KEK-A and KEK-B are two +/// distinct 32-byte keys (distinct derived kek_ids). +/// +public sealed class KekDoctorTests : IAsyncLifetime, IDisposable +{ + private readonly string _dbPath = + Path.Combine(Path.GetTempPath(), $"zb-secrets-doctor-{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). + private readonly LiteralMasterKeyProvider _kekA = + new(Convert.ToBase64String(FillKey(0xA1))); + private readonly LiteralMasterKeyProvider _kekB = + new(Convert.ToBase64String(FillKey(0xB2))); + + public KekDoctorTests() + { + _factory = new SecretsSqliteConnectionFactory(_dbPath); + _store = new SqliteSecretStore(_factory); + } + + 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; + } + + // 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", + }; + + // A degraded session: no KEK, no cipher (metadata-only). + private SecretsSession DegradedSession() => new() + { + Target = new TargetConfigReader().Manual(_dbPath, new MasterKeyOptions()), + Store = _store, + Migrator = new SqliteSecretsStoreMigrator(_factory), + MasterKey = null, + Cipher = null, + 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); + } + + [Fact] + public async Task Diagnose_reports_ok_rows_under_session_kek() + { + await SeedAsync("sql/a", "value-a", _kekA); + await SeedAsync("ldap/b", "value-b", _kekA); + + KekDoctorReport report = await new KekDoctor().DiagnoseAsync(Session(_kekA), CancellationToken.None); + + Assert.True(report.Healthy); + Assert.Equal(_kekA.KekId, report.SessionKekId); + Assert.Equal(2, report.Rows.Count); + Assert.All(report.Rows, r => Assert.Equal(RowKekStatus.Ok, r.Status)); + // Rows ordered by name: "ldap/b" precedes "sql/a". + Assert.Equal(["ldap/b", "sql/a"], report.Rows.Select(r => r.SecretName)); + } + + [Fact] + public async Task Diagnose_reports_wrong_kek_rows_with_their_kekid() + { + // Rows sealed under A; the session runs on B → the operator must learn A is the KEK to hunt. + await SeedAsync("sql/a", "value-a", _kekA); + + KekDoctorReport report = await new KekDoctor().DiagnoseAsync(Session(_kekB), CancellationToken.None); + + Assert.False(report.Healthy); + Assert.Equal(_kekB.KekId, report.SessionKekId); + KekDiagnosis row = Assert.Single(report.Rows); + Assert.Equal(RowKekStatus.WrongKek, row.Status); + Assert.Equal(_kekA.KekId, row.RowKekId); + } + + [Fact] + public async Task Diagnose_reports_corrupt_row_when_kekid_matches_but_unwrap_fails() + { + await SeedAsync("sql/a", "value-a", _kekA); + + // Tamper one byte of the DEK wrap tag: kek_id still matches the session KEK, but the unwrap + // (and therefore the decrypt probe) now fails closed. UpsertAsync bumps revision — irrelevant. + StoredSecret row = (await _store.GetAsync(new SecretName("sql/a"), CancellationToken.None))!; + byte[] tag = (byte[])row.WrapTag.Clone(); + tag[0] ^= 0xFF; + await _store.UpsertAsync(row with { WrapTag = tag }, CancellationToken.None); + + KekDoctorReport report = await new KekDoctor().DiagnoseAsync(Session(_kekA), CancellationToken.None); + + Assert.False(report.Healthy); + KekDiagnosis diag = Assert.Single(report.Rows); + Assert.Equal(RowKekStatus.Corrupt, diag.Status); + Assert.Equal(_kekA.KekId, diag.RowKekId); + } + + [Fact] + public async Task Diagnose_includes_tombstoned_rows() + { + await SeedAsync("sql/live", "live", _kekA); + await SeedAsync("sql/dead", "dead", _kekA); + await _store.DeleteAsync(new SecretName("sql/dead"), "carol", CancellationToken.None); + + KekDoctorReport report = await new KekDoctor().DiagnoseAsync(Session(_kekA), CancellationToken.None); + + // A tombstoned row still appears — it blocks rewrap-all (it carries a KEK-wrapped DEK). + Assert.Equal(2, report.Rows.Count); + Assert.Contains(report.Rows, r => r.SecretName == "sql/dead"); + } + + [Fact] + public async Task Diagnose_throws_on_degraded_session() + { + InvalidOperationException ex = await Assert.ThrowsAsync( + () => new KekDoctor().DiagnoseAsync(DegradedSession(), CancellationToken.None)); + + Assert.Contains("key", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RewrapAll_moves_wrong_kek_rows_onto_session_kek() + { + await SeedAsync("sql/a", "value-a", _kekA); + await SeedAsync("sql/b", "value-b", _kekA); + + // Session runs on B; rows are on A → rewrap A onto the session KEK (B). + SecretsSession session = Session(_kekB); + RewrapReport report = await new KekDoctor().RewrapAllAsync(session, _kekA, CancellationToken.None); + + Assert.Equal(2, report.Total); + Assert.Equal(2, report.Rewrapped); + Assert.Equal(0, report.AlreadyCurrent); + + // Rows now decrypt under the session KEK (B), and the doctor re-diagnoses healthy. + var cipherB = new AesGcmEnvelopeCipher(_kekB); + StoredSecret a = (await _store.GetAsync(new SecretName("sql/a"), CancellationToken.None))!; + Assert.Equal(_kekB.KekId, a.KekId); + Assert.Equal("value-a", cipherB.Decrypt(a)); + + KekDoctorReport after = await new KekDoctor().DiagnoseAsync(session, CancellationToken.None); + Assert.True(after.Healthy); + } + + 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. + } + } + } +}