168 lines
8.6 KiB
C#
168 lines
8.6 KiB
C#
using ZB.MOM.WW.Secrets.Abstractions;
|
|
using ZB.MOM.WW.Secrets.Rotation;
|
|
|
|
namespace ZB.MOM.WW.Secrets.Cli.Interactive;
|
|
|
|
/// <summary>The per-row verdict of a KEK diagnosis.</summary>
|
|
public enum RowKekStatus
|
|
{
|
|
/// <summary>The session KEK matches the row and successfully unwraps it — the row is healthy.</summary>
|
|
Ok,
|
|
|
|
/// <summary>The row is wrapped under a different KEK than the session's — the session cannot open it.</summary>
|
|
WrongKek,
|
|
|
|
/// <summary>
|
|
/// The row's <c>kek_id</c> matches the session KEK, yet the DEK unwrap / decrypt fails closed —
|
|
/// the wrap envelope or sealed body is damaged.
|
|
/// </summary>
|
|
Corrupt,
|
|
}
|
|
|
|
/// <summary>One row's KEK verdict: its name, status, and the <c>kek_id</c> it is actually wrapped under.</summary>
|
|
/// <param name="SecretName">The secret's normalized name.</param>
|
|
/// <param name="Status">Whether the session KEK opens the row (<see cref="RowKekStatus"/>).</param>
|
|
/// <param name="RowKekId">
|
|
/// The <c>kek_id</c> stamped on the row — the key an operator must hunt down when the status is
|
|
/// <see cref="RowKekStatus.WrongKek"/>.
|
|
/// </param>
|
|
public sealed record KekDiagnosis(string SecretName, RowKekStatus Status, string RowKekId);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </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>
|
|
/// <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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The store surface makes a full decrypt probe possible even for tombstoned rows:
|
|
/// <see cref="ISecretStore.ListAsync"/> enumerates all rows' metadata (including tombstones with
|
|
/// <c>includeDeleted: true</c>) and <see cref="ISecretStore.GetAsync"/> returns the full ciphertext
|
|
/// row for any name <b>including a tombstoned one</b>, so every row — live or dead — is probed the
|
|
/// same way. Plaintext produced by the probe is discarded immediately and never surfaced.
|
|
/// </remarks>
|
|
public sealed class KekDoctor
|
|
{
|
|
/// <summary>
|
|
/// Diagnoses every stored row (including tombstones) against the session KEK: a row whose
|
|
/// <c>kek_id</c> differs from the session KEK is reported <see cref="RowKekStatus.WrongKek"/>
|
|
/// with no decrypt attempted; a row whose <c>kek_id</c> matches is decrypt-probed and reported
|
|
/// <see cref="RowKekStatus.Ok"/> or, if the unwrap fails closed, <see cref="RowKekStatus.Corrupt"/>.
|
|
/// </summary>
|
|
/// <param name="session">The open session; must not be degraded (a KEK is required).</param>
|
|
/// <param name="ct">A token to cancel the diagnosis.</param>
|
|
/// <returns>A <see cref="KekDoctorReport"/> with the per-row verdicts, ordered by name.</returns>
|
|
/// <exception cref="ArgumentNullException"><paramref name="session"/> is <see langword="null"/>.</exception>
|
|
/// <exception cref="InvalidOperationException">
|
|
/// The session is degraded (no KEK / cipher). The operator must re-open the session supplying a
|
|
/// master key before the doctor can probe rows.
|
|
/// </exception>
|
|
public async Task<KekDoctorReport> 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<SecretMetadata> all =
|
|
await session.Store.ListAsync(includeDeleted: true, ct).ConfigureAwait(false);
|
|
|
|
List<KekDiagnosis> rows = [];
|
|
foreach (SecretMetadata meta in all.OrderBy(m => m.Name.Value, StringComparer.Ordinal))
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
// 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). Not added to
|
|
// rows; the Total-vs-Rows.Count gap in the report keeps the drop visible.
|
|
continue;
|
|
}
|
|
|
|
rows.Add(Classify(row, sessionKekId, session.Cipher));
|
|
}
|
|
|
|
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>
|
|
/// Remedies wrong-KEK rows by re-wrapping every row from <paramref name="oldKek"/> onto the
|
|
/// session's KEK, delegating to <see cref="KekRotationService.RewrapAllAsync"/> (idempotent, and
|
|
/// fail-closed on rows wrapped by neither the old nor the session KEK).
|
|
/// </summary>
|
|
/// <param name="session">The open session; must not be degraded (its KEK is the rewrap target).</param>
|
|
/// <param name="oldKek">The provider for the KEK the wrong-KEK rows are currently wrapped under.</param>
|
|
/// <param name="ct">A token to cancel the pass (already-persisted re-wraps are retained).</param>
|
|
/// <returns>A <see cref="RewrapReport"/> summarizing the pass (no secret material).</returns>
|
|
/// <exception cref="ArgumentNullException">
|
|
/// <paramref name="session"/> or <paramref name="oldKek"/> is <see langword="null"/>.
|
|
/// </exception>
|
|
/// <exception cref="InvalidOperationException">The session is degraded (no KEK / cipher).</exception>
|
|
public Task<RewrapReport> 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);
|
|
}
|
|
}
|