feat(secrets): build G-8 KEK rotation (RewrapAll); design+plan G-7 clustered replication

G-8 (KEK rotation) — built in ZB.MOM.WW.Secrets, lib 0.1.2->0.1.3:
- ISecretCipher.Rewrap(row, oldKek, newKek): re-wraps the per-secret DEK only
  (bodies never re-encrypted; revision/timestamps preserved -> invisible to
  cluster LWW). Fail-closed on wrong old-KEK id, wrong key bytes, and malformed
  wraps; DEK zeroed on all paths.
- ISecretStore.ApplyRewrapAsync(rewrappedRow, expectedCurrentWrappedDek):
  updates only the 4 wrap columns + kek_id, compare-and-swap on the current
  wrapped DEK so a concurrent set/rotate cannot corrupt a row (closes a
  review-caught TOCTOU).
- KekRotationService.RewrapAllAsync + RewrapReport: enumerate all rows incl.
  tombstones, idempotent/resumable skip-already-current, bounded CAS-retry,
  fail-closed on unknown/identical KEK.
- `secret rewrap-all` CLI verb: key material only via env-var name / file path,
  JSON counts report; README section + operator runbook.

Verified: full offline suite green (82 core + 15 UI, 0 regressions) + end-to-end
CLI smoke + adversarial crypto review (all 7 categories PASS; TOCTOU fixed).

G-7 (clustered replication) — designed + planned, no code:
- Fork resolved to build Option A (shared SQL-Server ISecretStore); Akka
  replicator ZB.MOM.WW.Secrets.Akka is a deferred phase-2. Design doc +
  executable plan + .tasks.json under docs/plans/2026-07-17-secrets-g7-*.

Tracking: components/secrets/GAPS.md + CLAUDE.md secrets row updated.
This commit is contained in:
Joseph Doherty
2026-07-17 02:55:43 -04:00
parent b009507e10
commit d82d3451e7
24 changed files with 1280 additions and 12 deletions
@@ -32,4 +32,34 @@ public interface ISecretCipher
/// The authentication tag fails to verify, or the row references an unknown or unavailable KEK.
/// </exception>
string Decrypt(StoredSecret secret);
/// <summary>
/// Re-wraps the data-encryption key (DEK) of an existing row from <paramref name="oldKek"/> to
/// <paramref name="newKek"/> — the KEK-rotation primitive. The DEK is unwrapped under the old
/// master key and re-wrapped under the new one; the sealed body
/// (<see cref="StoredSecret.Ciphertext"/>/<see cref="StoredSecret.Nonce"/>/<see cref="StoredSecret.Tag"/>)
/// is <b>never</b> re-encrypted, and <see cref="StoredSecret.Revision"/>, the timestamps, the
/// tombstone flag, and every other field are preserved verbatim. Only
/// <see cref="StoredSecret.WrappedDek"/>/<see cref="StoredSecret.WrapNonce"/>/<see cref="StoredSecret.WrapTag"/>
/// and <see cref="StoredSecret.KekId"/> change. Plaintext is never exposed.
/// </summary>
/// <remarks>
/// This operation is <i>provider-explicit</i>: it uses only the two supplied providers and does
/// not consult whatever KEK the cipher instance was constructed with, because a rotation spans
/// two keys. The caller must have already established that <paramref name="secret"/> is wrapped
/// by <paramref name="oldKek"/> (the row's <see cref="StoredSecret.KekId"/> equals
/// <see cref="IMasterKeyProvider.KekId"/> of <paramref name="oldKek"/>); a mismatch fails closed.
/// </remarks>
/// <param name="secret">The stored row whose DEK should be re-wrapped.</param>
/// <param name="oldKek">The provider for the KEK that currently wraps the row's DEK.</param>
/// <param name="newKek">The provider for the KEK the DEK should be re-wrapped under.</param>
/// <returns>
/// A copy of <paramref name="secret"/> with the DEK re-wrapped under <paramref name="newKek"/>
/// and <see cref="StoredSecret.KekId"/> set to the new KEK id; all other fields unchanged.
/// </returns>
/// <exception cref="SecretDecryptionException">
/// The row is not wrapped by <paramref name="oldKek"/>, or the DEK fails to unwrap (wrong old
/// key material or a tampered wrap).
/// </exception>
StoredSecret Rewrap(StoredSecret secret, IMasterKeyProvider oldKek, IMasterKeyProvider newKek);
}
@@ -63,4 +63,42 @@ public interface ISecretStore
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that completes when the row has been applied or ignored.</returns>
Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct);
/// <summary>
/// Applies a KEK re-wrap in place: overwrites <b>only</b> the KEK-wrap envelope columns
/// (<see cref="StoredSecret.WrappedDek"/>/<see cref="StoredSecret.WrapNonce"/>/<see cref="StoredSecret.WrapTag"/>)
/// and <see cref="StoredSecret.KekId"/> for the row named by <paramref name="rewrappedRow"/>,
/// leaving the sealed body, revision, timestamps, tombstone flag, and audit stamps untouched.
/// </summary>
/// <remarks>
/// <para>
/// This is the persistence half of KEK rotation. It deliberately does <b>not</b> bump the
/// revision or refresh <c>updated_utc</c>: a re-wrap does not change the secret's plaintext, so
/// it must stay invisible to the (updated_utc, revision) last-writer-wins ordering used by
/// cluster anti-entropy — otherwise every node's independent re-wrap would churn replication.
/// </para>
/// <para>
/// The update is a <b>compare-and-swap</b> on <paramref name="expectedCurrentWrappedDek"/>: the
/// row is updated only if its current <see cref="StoredSecret.WrappedDek"/> still equals the DEK
/// wrap that was unwrapped to produce <paramref name="rewrappedRow"/>. A concurrent write to the
/// same secret (a <c>set</c>/<c>rotate</c> that generated a new DEK) changes that wrap, so the
/// CAS matches 0 rows and the row is left untouched — rather than corrupting it by pairing a
/// stale re-wrap with a newer body. The caller re-processes a 0-row result (fetch → re-wrap).
/// </para>
/// </remarks>
/// <param name="rewrappedRow">
/// The row carrying the new wrap envelope + <see cref="StoredSecret.KekId"/>; matched by
/// <see cref="StoredSecret.Name"/>. Only the four wrap-related columns are read from it.
/// </param>
/// <param name="expectedCurrentWrappedDek">
/// The <see cref="StoredSecret.WrappedDek"/> the row must still carry for the swap to apply — the
/// wrap that was unwrapped to produce <paramref name="rewrappedRow"/>.
/// </param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>
/// <c>true</c> if the row was updated; <c>false</c> if no row matched (absent, or its wrapped DEK
/// changed under a concurrent write).
/// </returns>
Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct);
}