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);
}
@@ -1,9 +1,11 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.MasterKey;
using ZB.MOM.WW.Secrets.Sqlite;
// Headless `secret` CLI: set / get / list / rm / rotate over the ZB.MOM.WW secrets store.
@@ -77,6 +79,41 @@ try
return await commands.RemoveAsync(args[1], actor, ct);
}
case "rewrap-all":
{
// Rotate the master KEK by re-wrapping every row's DEK. Key material is supplied ONLY by
// env-var NAME or file PATH — never as a literal argument (which would be echoed/logged).
string? oldKind = null, oldValue = null, oldKekId = null;
string? newKind = null, newValue = null, newKekId = null;
for (int i = 1; i < args.Length; i++)
{
switch (args[i])
{
case "--old-key-env": (oldKind, oldValue) = ("env", RequireValue(args, ref i)); break;
case "--old-key-file": (oldKind, oldValue) = ("file", RequireValue(args, ref i)); break;
case "--old-key-id": oldKekId = RequireValue(args, ref i); break;
case "--new-key-env": (newKind, newValue) = ("env", RequireValue(args, ref i)); break;
case "--new-key-file": (newKind, newValue) = ("file", RequireValue(args, ref i)); break;
case "--new-key-id": newKekId = RequireValue(args, ref i); break;
default: return Usage($"Unknown option '{args[i]}'.");
}
}
if (oldKind is null)
return Usage("rewrap-all requires --old-key-env <VAR> or --old-key-file <path>.");
if (newKind is null && newKekId is not null)
return Usage("--new-key-id requires --new-key-env <VAR> or --new-key-file <path>.");
IMasterKeyProvider oldKek = BuildProvider(oldKind, oldValue!, oldKekId);
// Default the new KEK to the app's configured provider (Secrets:MasterKey); override with flags.
IMasterKeyProvider newKek = newKind is null
? host.Services.GetRequiredService<IMasterKeyProvider>()
: BuildProvider(newKind, newValue!, newKekId);
return await commands.RewrapAllAsync(oldKek, newKek, ct);
}
default:
return Usage($"Unknown command '{args[0]}'.");
}
@@ -132,6 +169,26 @@ static SecretContentType ParseContentType(string value) => value switch
$"Unknown --content-type '{value}'; expected text|connection-string|json|binary-base64."),
};
// Reads the value that must follow an option flag at args[i], advancing i past it.
static string RequireValue(string[] args, ref int i)
{
if (i + 1 >= args.Length)
throw new ArgumentException($"{args[i]} requires a value.");
return args[++i];
}
// Builds a master-key provider from a source kind ("env" | "file") + its env-var name or file path,
// with an optional explicit KEK id (needed when the app configured Secrets:MasterKey:KekId rather
// than the derived sha256: id). The key VALUE itself is only ever read from the env var / file.
static IMasterKeyProvider BuildProvider(string kind, string value, string? kekId) => kind switch
{
"env" => MasterKeyProviderFactory.Create(
new MasterKeyOptions { Source = MasterKeySource.Environment, EnvVarName = value, KekId = kekId }),
"file" => MasterKeyProviderFactory.Create(
new MasterKeyOptions { Source = MasterKeySource.File, FilePath = value, KekId = kekId }),
_ => throw new ArgumentException($"Unknown key source '{kind}'."),
};
// Prints usage (optionally preceded by an error line) and returns the standard usage exit code.
int Usage(string? error = null)
{
@@ -147,6 +204,14 @@ int Usage(string? error = null)
get <name>
list [--include-deleted]
rm <name>
rewrap-all --old-key-env <VAR>|--old-key-file <path> [--old-key-id <id>]
[--new-key-env <VAR>|--new-key-file <path>] [--new-key-id <id>]
rewrap-all rotates the master KEK: it re-wraps every stored secret's data key from the old
KEK to the new one (bodies are never re-encrypted, no plaintext is exposed). The new KEK
defaults to the configured Secrets:MasterKey provider. Supply key material only by env-var
NAME or file PATH never as a literal. Run it with resolve traffic quiesced; it is
idempotent and safe to re-run.
""");
return 2;
}
@@ -1,5 +1,6 @@
using System.Text.Json;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Rotation;
namespace ZB.MOM.WW.Secrets.Cli;
@@ -156,6 +157,47 @@ public sealed class SecretCommands
return removed ? 0 : 1;
}
/// <summary>
/// rewrap-all: rotates the master KEK by re-wrapping every row's DEK from <paramref name="oldKek"/>
/// onto <paramref name="newKek"/> — bodies are never re-encrypted and no plaintext is exposed.
/// Prints a JSON report of the counts (total / rewrapped / already-current) and NEVER any key
/// material. Idempotent + resumable; fails closed (non-zero) on an identical-KEK mistake or a row
/// wrapped by an unrecognized KEK.
/// </summary>
/// <param name="oldKek">The provider for the KEK rows are currently wrapped under.</param>
/// <param name="newKek">The provider for the KEK rows should be re-wrapped onto.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 on success; non-zero if the rotation was rejected or aborted.</returns>
public async Task<int> RewrapAllAsync(
IMasterKeyProvider oldKek, IMasterKeyProvider newKek, CancellationToken ct)
{
var service = new KekRotationService(_store, _cipher);
try
{
RewrapReport report = await service.RewrapAllAsync(oldKek, newKek, ct).ConfigureAwait(false);
WriteJson(new
{
action = "rewrap-all",
total = report.Total,
rewrapped = report.Rewrapped,
alreadyCurrent = report.AlreadyCurrent,
});
return 0;
}
catch (ArgumentException ex)
{
// Identical old/new KEK ids — nothing to rotate.
WriteJson(new { action = "rewrap-all", error = ex.Message });
return 1;
}
catch (SecretDecryptionException ex)
{
// A row on an unrecognized KEK aborted the pass, or a DEK failed to unwrap.
WriteJson(new { action = "rewrap-all", error = ex.Message });
return 1;
}
}
/// <summary>Seals a value under the cipher and upserts it, carrying description + actor stamps.</summary>
private async Task UpsertSealedAsync(
SecretName name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
@@ -153,4 +153,75 @@ public sealed class AesGcmEnvelopeCipher : ISecretCipher
CryptographicOperations.ZeroMemory(dek);
}
}
/// <inheritdoc />
public StoredSecret Rewrap(StoredSecret secret, IMasterKeyProvider oldKek, IMasterKeyProvider newKek)
{
ArgumentNullException.ThrowIfNull(secret);
ArgumentNullException.ThrowIfNull(oldKek);
ArgumentNullException.ThrowIfNull(newKek);
// Fail closed if the row is not actually wrapped by the supplied old KEK — a caller must
// not silently "rewrap" a row that is already on a different key.
if (!string.Equals(secret.KekId, oldKek.KekId, StringComparison.Ordinal))
{
throw new SecretDecryptionException(
$"Cannot rewrap secret '{secret.Name.Value}': row is wrapped by KEK '{secret.KekId}', " +
$"not the supplied old KEK '{oldKek.KekId}'.");
}
ReadOnlySpan<byte> oldMasterKey = oldKek.GetMasterKey().Span;
ReadOnlySpan<byte> newMasterKey = newKek.GetMasterKey().Span;
string newKekId = newKek.KekId;
byte[] dek = new byte[KeySizeBytes];
try
{
// Unwrap the DEK under the OLD master key, verifying the old KEK-id AAD binding. A wrong
// old key or tampered wrap surfaces as SecretDecryptionException (never a raw crypto leak).
byte[] oldKekAad = Encoding.UTF8.GetBytes(secret.KekId);
try
{
using var unwrap = new AesGcm(oldMasterKey, TagSizeBytes);
unwrap.Decrypt(secret.WrapNonce, secret.WrappedDek, secret.WrapTag, dek, oldKekAad);
}
// CryptographicException = wrong key / tampered wrap; ArgumentException = a malformed
// (wrong-length) wrap nonce/tag on a corrupt row. Both fail closed as a decryption error
// rather than leaking a raw crypto/arg exception (which the caller would misclassify).
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
{
throw new SecretDecryptionException(
$"Failed to rewrap secret '{secret.Name.Value}': the DEK could not be unwrapped " +
"under the supplied old KEK.", ex);
}
// Re-wrap the SAME DEK under the NEW master key with a fresh nonce, binding the new KEK id
// as AAD. The body ciphertext is left untouched — rotation re-wraps DEKs only.
byte[] wrapNonce = new byte[NonceSizeBytes];
byte[] wrapTag = new byte[TagSizeBytes];
byte[] wrappedDek = new byte[KeySizeBytes];
byte[] newKekAad = Encoding.UTF8.GetBytes(newKekId);
RandomNumberGenerator.Fill(wrapNonce);
using (var wrapGcm = new AesGcm(newMasterKey, TagSizeBytes))
{
wrapGcm.Encrypt(wrapNonce, dek, wrappedDek, wrapTag, newKekAad);
}
// Only the KEK-wrap envelope + kek_id change; body, revision, timestamps, tombstone,
// content-type, and every other field are preserved so a rewrap is invisible to the
// (updated_utc, revision) last-writer-wins ordering used by cluster anti-entropy.
return secret with
{
WrappedDek = wrappedDek,
WrapNonce = wrapNonce,
WrapTag = wrapTag,
KekId = newKekId,
};
}
finally
{
CryptographicOperations.ZeroMemory(dek);
}
}
}
@@ -0,0 +1,155 @@
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Rotation;
/// <summary>
/// Rotates the master key-encryption key (KEK) across every stored secret by re-wrapping each
/// row's data-encryption key (DEK) from the old KEK to the new one. Because the envelope model
/// wraps a per-secret DEK under the KEK, rotation never re-encrypts secret bodies — only the DEK
/// wrap changes — so plaintext is never exposed and no value history is touched.
/// </summary>
/// <remarks>
/// <para>
/// The pass is <b>idempotent and resumable</b>: a row already wrapped by the new KEK is skipped
/// (counted in <see cref="RewrapReport.AlreadyCurrent"/>), so a run interrupted part-way can simply
/// be re-run to finish. It is <b>fail-closed</b>: a row wrapped by neither the old nor the new KEK
/// aborts the pass (rather than silently leaving an un-migratable row that would become
/// undecryptable once the old KEK is retired); rows already re-wrapped in the aborted run stay
/// persisted, so a re-run resumes cleanly after the anomaly is investigated.
/// </para>
/// <para>
/// Rotation is a per-store operation and must be run once per independent store (once for a shared
/// SQL-Server store; once per node for a per-node SQLite store on a shared KEK). It should be run
/// with resolve traffic quiesced: the persistence path deliberately does not bump revision or
/// <c>updated_utc</c>, so a re-wrap is invisible to cluster last-writer-wins reconciliation.
/// </para>
/// </remarks>
public sealed class KekRotationService
{
private readonly ISecretStore _store;
private readonly ISecretCipher _cipher;
/// <summary>Creates the rotation service over the encrypted store and envelope cipher.</summary>
/// <param name="store">The store to enumerate and re-wrap in place.</param>
/// <param name="cipher">The envelope cipher providing the DEK re-wrap primitive.</param>
public KekRotationService(ISecretStore store, ISecretCipher cipher)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
}
/// <summary>
/// Re-wraps every row from <paramref name="oldKek"/> onto <paramref name="newKek"/>.
/// </summary>
/// <param name="oldKek">The provider for the KEK rows are currently wrapped under.</param>
/// <param name="newKek">The provider for the KEK rows should be re-wrapped onto.</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="ArgumentException">
/// The old and new KEK identifiers are the same — there is nothing to rotate.
/// </exception>
/// <exception cref="SecretDecryptionException">
/// A row is wrapped by a KEK that is neither the old nor the new one (the pass aborts), or a
/// row's DEK fails to unwrap under the supplied old KEK.
/// </exception>
public async Task<RewrapReport> RewrapAllAsync(
IMasterKeyProvider oldKek, IMasterKeyProvider newKek, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(oldKek);
ArgumentNullException.ThrowIfNull(newKek);
if (string.Equals(oldKek.KekId, newKek.KekId, StringComparison.Ordinal))
{
throw new ArgumentException(
$"The old and new KEK identifiers are identical ('{oldKek.KekId}'); there is nothing to rotate.",
nameof(newKek));
}
// Enumerate metadata (never ciphertext) for ALL rows including tombstones — a tombstoned row
// still carries a KEK-wrapped DEK and would become un-migratable if left on a retired KEK.
IReadOnlyList<SecretMetadata> all =
await _store.ListAsync(includeDeleted: true, ct).ConfigureAwait(false);
int rewrapped = 0;
int alreadyCurrent = 0;
foreach (SecretMetadata meta in all)
{
ct.ThrowIfCancellationRequested();
// Fast-path skip on the metadata kek_id (no ciphertext fetch): a row already on the new
// KEK is a completed migration — this is what makes the pass idempotent / resumable.
if (string.Equals(meta.KekId, newKek.KekId, StringComparison.Ordinal))
{
alreadyCurrent++;
continue;
}
switch (await RewrapOneAsync(meta.Name, oldKek, newKek, rewrapped, ct).ConfigureAwait(false))
{
case RowOutcome.Rewrapped:
rewrapped++;
break;
case RowOutcome.AlreadyCurrent:
alreadyCurrent++;
break;
case RowOutcome.Vanished:
break;
}
}
return new RewrapReport { Total = all.Count, Rewrapped = rewrapped, AlreadyCurrent = alreadyCurrent };
}
private enum RowOutcome { Rewrapped, AlreadyCurrent, Vanished }
// Re-wraps a single row with a bounded fetch→rewrap→compare-and-swap retry so a rare concurrent
// write (the pass should run with writes quiesced) is retried rather than corrupting the row.
private async Task<RowOutcome> RewrapOneAsync(
SecretName name, IMasterKeyProvider oldKek, IMasterKeyProvider newKek, int rewrappedSoFar, CancellationToken ct)
{
const int maxAttempts = 5;
for (int attempt = 1; ; attempt++)
{
ct.ThrowIfCancellationRequested();
StoredSecret? row = await _store.GetAsync(name, ct).ConfigureAwait(false);
if (row is null)
{
// Vanished between the list and the fetch (a concurrent hard-absence).
return RowOutcome.Vanished;
}
// Re-evaluate against the FRESH row: a concurrent write may have already moved it.
if (string.Equals(row.KekId, newKek.KekId, StringComparison.Ordinal))
{
return RowOutcome.AlreadyCurrent;
}
if (!string.Equals(row.KekId, oldKek.KekId, StringComparison.Ordinal))
{
throw new SecretDecryptionException(
$"Secret '{name.Value}' is wrapped by KEK '{row.KekId}', which is neither the old " +
$"('{oldKek.KekId}') nor the new ('{newKek.KekId}') KEK. Rotation aborted after re-wrapping " +
$"{rewrappedSoFar} row(s); investigate the anomalous row and re-run to resume.");
}
// Rewrap re-verifies row.KekId == oldKek.KekId and fails closed on a wrong old key.
StoredSecret rewrappedRow = _cipher.Rewrap(row, oldKek, newKek);
// Compare-and-swap on the wrap we just unwrapped: a concurrent set/rotate changed it →
// 0 rows → retry (fetch the newer row and re-evaluate).
if (await _store.ApplyRewrapAsync(rewrappedRow, row.WrappedDek, ct).ConfigureAwait(false))
{
return RowOutcome.Rewrapped;
}
if (attempt >= maxAttempts)
{
throw new SecretDecryptionException(
$"Secret '{name.Value}' is being modified concurrently; rotation could not converge after " +
$"{maxAttempts} attempts. Re-run with secret writes quiesced.");
}
}
}
}
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.Secrets.Rotation;
/// <summary>
/// The outcome of a <see cref="KekRotationService.RewrapAllAsync"/> pass: how many rows were
/// re-wrapped onto the new KEK, how many were already on it (skipped), and the total scanned.
/// Carries no secret material.
/// </summary>
public sealed record RewrapReport
{
/// <summary>Total number of rows scanned (includes tombstoned rows).</summary>
public required int Total { get; init; }
/// <summary>Number of rows re-wrapped from the old KEK onto the new KEK in this pass.</summary>
public required int Rewrapped { get; init; }
/// <summary>
/// Number of rows already wrapped by the new KEK and therefore skipped — the count that makes a
/// re-run idempotent (a completed rotation reports <see cref="Rewrapped"/> 0 and all rows here).
/// </summary>
public required int AlreadyCurrent { get; init; }
}
@@ -260,6 +260,43 @@ public sealed class SqliteSecretStore(SecretsSqliteConnectionFactory connectionF
await transaction.CommitAsync(ct).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(rewrappedRow);
ArgumentNullException.ThrowIfNull(expectedCurrentWrappedDek);
await using SqliteConnection connection =
await connectionFactory.OpenConnectionAsync(ct).ConfigureAwait(false);
// Overwrite ONLY the KEK-wrap envelope + kek_id. Revision, updated_utc/by, the sealed body,
// and the tombstone state are deliberately untouched — a re-wrap is not a logical change, so
// it must not bump the revision or updated timestamp (see ISecretStore.ApplyRewrapAsync).
//
// Compare-and-swap on wrapped_dek: a concurrent set/rotate generates a fresh (random) DEK
// wrap, so this matches 0 rows instead of pairing this stale re-wrap with a newer body —
// which would leave the row permanently undecryptable. The caller re-processes a 0-row miss.
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE secret SET
wrapped_dek = $wrapped_dek,
wrap_nonce = $wrap_nonce,
wrap_tag = $wrap_tag,
kek_id = $kek_id
WHERE name = $name AND wrapped_dek = $expected_wrapped_dek;
""";
command.Parameters.AddWithValue("$wrapped_dek", rewrappedRow.WrappedDek);
command.Parameters.AddWithValue("$wrap_nonce", rewrappedRow.WrapNonce);
command.Parameters.AddWithValue("$wrap_tag", rewrappedRow.WrapTag);
command.Parameters.AddWithValue("$kek_id", rewrappedRow.KekId);
command.Parameters.AddWithValue("$name", rewrappedRow.Name.Value);
command.Parameters.AddWithValue("$expected_wrapped_dek", expectedCurrentWrappedDek);
int rowsAffected = await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
return rowsAffected > 0;
}
// Binds the identity, description, content-type, KEK id, and all six crypto BLOB columns
// shared by every insert path.
private static void BindCryptoColumns(SqliteCommand command, StoredSecret row)