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); } }