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.
///
/// The number of rows scanned from the store (including tombstones). A value greater than
/// 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.
///
public sealed record KekDoctorReport(string SessionKekId, IReadOnlyList Rows, int Total)
{
/// 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();
// 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);
}
}
///
/// 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);
}
}