fix(secrets-cli): KEK doctor — fresh-row verdicts, degraded rewrap guard test, vanished-row visibility

This commit is contained in:
Joseph Doherty
2026-07-19 09:33:53 -04:00
parent e60f2d5dad
commit 4576f73c4c
2 changed files with 63 additions and 24 deletions
@@ -34,7 +34,12 @@ public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string
/// </summary>
/// <param name="SessionKekId">The <c>kek_id</c> of the KEK the session is currently using.</param>
/// <param name="Rows">The per-row verdicts, ordered by secret name.</param>
public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList<KekDiagnosis> Rows)
/// <param name="Total">
/// The number of rows scanned from the store (including tombstones). A value greater than
/// <see cref="Rows"/> count means some rows vanished between the metadata list and the per-row fetch
/// (a concurrent hard-absence) and were dropped — the discrepancy makes that visible rather than silent.
/// </param>
public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList<KekDiagnosis> Rows, int Total)
{
/// <summary>Whether every diagnosed row opens cleanly under the session KEK.</summary>
public bool Healthy => Rows.All(r => r.Status == RowKekStatus.Ok);
@@ -92,37 +97,42 @@ public sealed class KekDoctor
{
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;
}
// Fetch the FRESH row and classify from ITS kek_id — never the (possibly stale) list
// metadata. A concurrent re-wrap between the list and this fetch would otherwise mislabel a
// moved row (a benign WrongKek row read as Corrupt, or a re-homed row read as WrongKek).
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.
// Vanished between the list and the fetch (a concurrent hard-absence). Not added to
// rows; the Total-vs-Rows.Count gap in the report keeps the drop visible.
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));
rows.Add(Classify(row, sessionKekId, session.Cipher));
}
return new KekDoctorReport(sessionKekId, rows);
return new KekDoctorReport(sessionKekId, rows, all.Count);
}
// Classifies a single FRESH row against the session KEK. A row under a different kek_id is
// WrongKek (reported with its own id so the operator knows which key to hunt) and is NOT decrypted;
// a matching row is decrypt-probed (plaintext discarded at once) → Ok, or Corrupt if it fails closed.
private static KekDiagnosis Classify(StoredSecret row, string sessionKekId, ISecretCipher cipher)
{
if (!string.Equals(row.KekId, sessionKekId, StringComparison.Ordinal))
{
return new KekDiagnosis(row.Name.Value, RowKekStatus.WrongKek, row.KekId);
}
try
{
_ = cipher.Decrypt(row);
return new KekDiagnosis(row.Name.Value, RowKekStatus.Ok, row.KekId);
}
catch (SecretDecryptionException)
{
return new KekDiagnosis(row.Name.Value, RowKekStatus.Corrupt, row.KekId);
}
}
/// <summary>