feat(secrets-cli): lockout-recovery flow — KEK diagnosis, guided rewrap, lost-KEK reset

This commit is contained in:
Joseph Doherty
2026-07-19 09:53:10 -04:00
parent 000d283c99
commit d1ec0fe5da
2 changed files with 450 additions and 0 deletions
@@ -0,0 +1,245 @@
using System.Globalization;
using Spectre.Console;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.MasterKey;
using ZB.MOM.WW.Secrets.Rotation;
namespace ZB.MOM.WW.Secrets.Cli.Interactive.Flows;
/// <summary>
/// The lockout-recovery flow. 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 (via
/// <see cref="KekDoctor.DiagnoseAsync"/>), then chooses a remedy for the wrong-KEK (and corrupt) rows:
/// <list type="bullet">
/// <item><b>Rewrap from old KEK</b> — the operator supplies the KEK the rows are currently wrapped under
/// (from an environment variable, a key file, or a pasted base64 key) and the flow re-wraps every row onto
/// the session KEK after an explicit, exactly-scoped confirmation. This is the non-destructive remedy: the
/// secret bodies are preserved, only the DEK wrap changes.</item>
/// <item><b>Old KEK is lost — re-set affected secrets</b> — when the old KEK is unrecoverable, each affected
/// row is offered a masked re-set (a fresh value sealed under the session KEK). This is the destructive
/// last resort and the only remedy for a <see cref="RowKekStatus.Corrupt"/> row (whose body cannot be
/// re-wrapped or preserved).</item>
/// </list>
/// Requires a KEK-capable session (the shell upgrades a degraded session before dispatch). No key material
/// or typed value is ever echoed: the pasted old KEK and re-set values use masked prompts, and every
/// operator-controlled string (secret names, KEK ids, error messages) is markup-escaped.
/// </summary>
public sealed class KekDoctorFlow : IInteractiveFlow
{
private const string RewrapChoice = "Rewrap from old KEK";
private const string LostKekChoice = "Old KEK is lost — re-set affected secrets";
private const string BackChoice = "Back";
private const string PasteSource = "Paste base64 key";
private const string EnvSource = "Environment variable";
private const string FileSource = "Key file";
/// <inheritdoc />
public string Title => "KEK doctor (lockout recovery)";
/// <inheritdoc />
public bool RequiresKek => true;
/// <inheritdoc />
public async Task RunAsync(IAnsiConsole console, SecretsSession session, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(console);
ArgumentNullException.ThrowIfNull(session);
var doctor = new KekDoctor();
// Diagnose is deliberately not wrapped: a degraded-session or store fault is the shell's to contain.
KekDoctorReport report = await doctor.DiagnoseAsync(session, ct).ConfigureAwait(false);
RenderSummary(console, report);
if (report.Healthy)
{
console.MarkupLine("[green]All rows open under the session KEK — no lockout. Nothing to remedy.[/]");
return;
}
string choice = console.Prompt(new SelectionPrompt<string>()
.Title("Choose a [yellow]remedy[/]")
.AddChoices(RewrapChoice, LostKekChoice, BackChoice));
switch (choice)
{
case RewrapChoice:
await RunRewrapAsync(console, session, doctor, report, ct).ConfigureAwait(false);
break;
case LostKekChoice:
await RunLostKekResetAsync(console, session, report, ct).ConfigureAwait(false);
break;
default:
console.MarkupLine("[grey]No changes made.[/]");
return;
}
// Re-diagnose so the operator sees the store's post-remedy state in one closing line.
KekDoctorReport after = await doctor.DiagnoseAsync(session, ct).ConfigureAwait(false);
console.MarkupLineInterpolated($"Re-diagnosis: {Summarize(after)}.");
}
// Renders the session KEK, the row total, the per-status counts, and the distinct foreign kek_ids the
// operator must hunt down. Every operator-controlled value rides an interpolated (auto-escaped) hole.
private static void RenderSummary(IAnsiConsole console, KekDoctorReport report)
{
console.MarkupLineInterpolated(
$"Session KEK [cyan]{report.SessionKekId}[/] — {report.Total} row(s) scanned.");
int ok = Count(report, RowKekStatus.Ok);
int wrong = Count(report, RowKekStatus.WrongKek);
int corrupt = Count(report, RowKekStatus.Corrupt);
console.MarkupLineInterpolated(
$"[green]{ok} Ok[/], [yellow]{wrong} wrong-KEK[/], [red]{corrupt} corrupt[/].");
IReadOnlyList<string> foreign = ForeignKekIds(report);
if (foreign.Count > 0)
{
console.MarkupLineInterpolated($"Foreign KEK id(s) seen: {string.Join(", ", foreign)}.");
}
}
// The guided rewrap remedy: acquire the old KEK provider, show EXACTLY what will change, and re-wrap
// only after an explicit confirmation (defaulting false). The rewrap itself is the only guarded region —
// a wrong pasted key surfaces as a SecretDecryptionException, an old==session KEK as an ArgumentException.
private static async Task RunRewrapAsync(
IAnsiConsole console, SecretsSession session, KekDoctor doctor, KekDoctorReport report, CancellationToken ct)
{
IMasterKeyProvider oldKek = PromptForOldKek(console);
try
{
string oldKekId = oldKek.KekId;
int moving = report.Rows.Count(r => string.Equals(r.RowKekId, oldKekId, StringComparison.Ordinal));
string confirmText =
$"Re-wrap {moving} row(s) from KEK '{Markup.Escape(oldKekId)}' → session KEK " +
$"'{Markup.Escape(report.SessionKekId)}'? (rewraps every row on the old KEK)";
if (!console.Prompt(new ConfirmationPrompt(confirmText) { DefaultValue = false }))
{
console.MarkupLine("[grey]Rewrap cancelled — no rows changed.[/]");
return;
}
RewrapReport result = await doctor.RewrapAllAsync(session, oldKek, ct).ConfigureAwait(false);
console.MarkupLineInterpolated(
$"[green]Re-wrapped {result.Rewrapped} row(s)[/] onto the session KEK ({result.AlreadyCurrent} already current) of {result.Total} total.");
}
catch (ArgumentException ex)
{
// Old and session KEK are identical — nothing to rotate.
console.MarkupLineInterpolated($"[red]Rewrap failed:[/] {ex.Message}");
}
catch (SecretDecryptionException ex)
{
// Wrong old key, or a row wrapped by neither the old nor the session KEK (the pass aborted;
// any rows re-wrapped before the anomaly stay persisted).
console.MarkupLineInterpolated($"[red]Rewrap failed:[/] {ex.Message}");
}
}
// Prompts for the old-KEK source and builds the matching provider. Paste feeds a masked base64 entry to
// the LiteralMasterKeyProvider; env/file defer to the shared MasterKeyProviderFactory.
private static IMasterKeyProvider PromptForOldKek(IAnsiConsole console)
{
string source = console.Prompt(new SelectionPrompt<string>()
.Title("Where is the [yellow]old KEK[/]?")
.AddChoices(EnvSource, FileSource, PasteSource));
switch (source)
{
case EnvSource:
string envVar = console.Prompt(new TextPrompt<string>(" Environment variable name:"));
return MasterKeyProviderFactory.Create(new MasterKeyOptions
{
Source = MasterKeySource.Environment,
EnvVarName = envVar,
});
case FileSource:
string path = console.Prompt(new TextPrompt<string>(" Key file path:"));
return MasterKeyProviderFactory.Create(new MasterKeyOptions
{
Source = MasterKeySource.File,
FilePath = path,
});
default:
string base64 = console.Prompt(
new TextPrompt<string>(" Paste the base64 old KEK:").Secret());
return new LiteralMasterKeyProvider(base64);
}
}
// The destructive last resort: for every wrong-KEK or corrupt row, offer a masked re-set of a fresh
// value sealed under the session KEK. A corrupt row is flagged first (re-set is its only remedy).
private static async Task RunLostKekResetAsync(
IAnsiConsole console, SecretsSession session, KekDoctorReport report, CancellationToken ct)
{
IReadOnlyList<KekDiagnosis> affected = report.Rows
.Where(r => r.Status is RowKekStatus.WrongKek or RowKekStatus.Corrupt)
.ToList();
if (affected.Count == 0)
{
console.MarkupLine("[grey]No wrong-KEK or corrupt rows to re-set.[/]");
return;
}
foreach (KekDiagnosis row in affected)
{
string escapedName = Markup.Escape(row.SecretName);
if (row.Status == RowKekStatus.Corrupt)
{
console.MarkupLineInterpolated(
$"[red]'{row.SecretName}' is corrupt under the session KEK — it cannot be re-wrapped or preserved; re-setting a fresh value is the only remedy.[/]");
}
if (!console.Prompt(new ConfirmationPrompt(
$"Re-set '{escapedName}' with a new value? (overwrites)") { DefaultValue = false }))
{
continue;
}
await ResetAsync(console, session, new SecretName(row.SecretName), ct).ConfigureAwait(false);
console.MarkupLineInterpolated($"[green]Re-set '{row.SecretName}' under the session KEK.[/]");
}
}
// Prompts for a masked value + content type and seals a fresh row under the session cipher, overwriting
// the wrong-KEK/corrupt row in place. Mirrors the seed-and-stamp shape used by the other flows.
private static async Task ResetAsync(
IAnsiConsole console, SecretsSession session, SecretName name, CancellationToken ct)
{
string value = console.Prompt(
new TextPrompt<string>($" New value for [green]{Markup.Escape(name.Value)}[/]:").Secret());
SecretContentType contentType = console.Prompt(
new SelectionPrompt<SecretContentType>().Title(" Content type").AddChoices(Enum.GetValues<SecretContentType>()));
StoredSecret row = session.Cipher!.Encrypt(name, value, contentType) with
{
CreatedBy = FlowPrompts.Actor,
UpdatedBy = FlowPrompts.Actor,
};
await session.Store.UpsertAsync(row, ct).ConfigureAwait(false);
}
private static int Count(KekDoctorReport report, RowKekStatus status) =>
report.Rows.Count(r => r.Status == status);
// The distinct foreign kek_ids (wrong-KEK rows only), ordered for a deterministic render.
private static IReadOnlyList<string> ForeignKekIds(KekDoctorReport report) =>
report.Rows
.Where(r => r.Status == RowKekStatus.WrongKek)
.Select(r => r.RowKekId)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
// A deterministic "N Status" count-by-status roll-up, ordered by the status enum.
private static string Summarize(KekDoctorReport report) =>
string.Join(", ", report.Rows
.GroupBy(r => r.Status)
.OrderBy(g => g.Key)
.Select(g => string.Create(CultureInfo.InvariantCulture, $"{g.Count()} {g.Key}")));
}