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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user