Files
scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs
T
Joseph Doherty d82d3451e7 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.
2026-07-17 02:55:43 -04:00

217 lines
10 KiB
C#

using System.Text.Json;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Rotation;
namespace ZB.MOM.WW.Secrets.Cli;
/// <summary>
/// The thin, front-end-agnostic command layer behind the <c>secret</c> CLI verbs
/// (<c>set</c> / <c>get</c> / <c>list</c> / <c>rm</c> / <c>rotate</c>). Each verb is an
/// <see cref="int"/>-returning task (0 on success, non-zero on error) and writes ALL output to the
/// injected <see cref="TextWriter"/> — never to <see cref="Console"/> directly — so the layer is
/// fully testable and can be hosted under any front-end. Confirmation and listing output is JSON
/// metadata only and NEVER carries a secret value; the sole value-exposing surface is
/// <see cref="GetAsync"/>, which intentionally prints the decrypted plaintext (see its remarks).
/// </summary>
public sealed class SecretCommands
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false };
private readonly ISecretStore _store;
private readonly ISecretCipher _cipher;
private readonly ISecretResolver _resolver;
private readonly TextWriter _output;
/// <summary>Creates the command set over the supplied core seams and output sink.</summary>
/// <param name="store">The encrypted store (upsert / delete / list).</param>
/// <param name="cipher">The envelope cipher used to seal values on set / rotate.</param>
/// <param name="resolver">The read seam used by <c>get</c> to resolve current plaintext.</param>
/// <param name="output">Sink for all command output (JSON confirmations, listings, values).</param>
public SecretCommands(ISecretStore store, ISecretCipher cipher, ISecretResolver resolver, TextWriter output)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_cipher = cipher ?? throw new ArgumentNullException(nameof(cipher));
_resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
_output = output ?? throw new ArgumentNullException(nameof(output));
}
/// <summary>
/// set: seals <paramref name="value"/> under a fresh per-secret DEK and upserts it (create or
/// overwrite-in-place). Prints a JSON confirmation carrying only the name and action — never the
/// value.
/// </summary>
/// <param name="name">The secret name.</param>
/// <param name="value">The plaintext to seal.</param>
/// <param name="contentType">How consumers should interpret the plaintext.</param>
/// <param name="description">Optional human-readable description.</param>
/// <param name="actor">Principal recorded as created_by / updated_by, if known.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 on success.</returns>
public async Task<int> SetAsync(
string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
{
var secretName = new SecretName(name);
await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
WriteJson(new { name = secretName.Value, action = "set" });
return 0;
}
/// <summary>
/// rotate: like <see cref="SetAsync"/>, but REQUIRES the secret to already exist (and not be
/// tombstoned). Rotating a missing secret prints a not-found error and returns non-zero without
/// creating anything. The confirmation carries only the name and action — never the value.
/// </summary>
/// <param name="name">The existing secret to rotate.</param>
/// <param name="value">The new plaintext to seal in place.</param>
/// <param name="contentType">How consumers should interpret the plaintext.</param>
/// <param name="description">Optional human-readable description.</param>
/// <param name="actor">Principal recorded as updated_by, if known.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 on success; non-zero if the secret does not exist.</returns>
public async Task<int> RotateAsync(
string name, string value, SecretContentType contentType, string? description, string? actor, CancellationToken ct)
{
var secretName = new SecretName(name);
StoredSecret? existing = await _store.GetAsync(secretName, ct).ConfigureAwait(false);
if (existing is null || existing.IsDeleted)
{
WriteJson(new { name = secretName.Value, error = "not-found" });
return 1;
}
await UpsertSealedAsync(secretName, value, contentType, description, actor, ct).ConfigureAwait(false);
WriteJson(new { name = secretName.Value, action = "rotate" });
return 0;
}
/// <summary>
/// get: resolves and prints the current DECRYPTED plaintext for <paramref name="name"/> to the
/// output sink.
/// </summary>
/// <remarks>
/// <b>This deliberately exposes the secret value.</b> It is the one command whose whole purpose
/// is to surface plaintext (to stdout), so an operator can pipe or capture it. Every other
/// command emits metadata only. A missing or tombstoned secret prints a JSON <c>not-found</c>
/// error and returns non-zero.
/// </remarks>
/// <param name="name">The secret to resolve.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 when the value was printed; non-zero when the secret is absent.</returns>
public async Task<int> GetAsync(string name, CancellationToken ct)
{
var secretName = new SecretName(name);
string? value = await _resolver.GetAsync(secretName, ct).ConfigureAwait(false);
if (value is null)
{
WriteJson(new { error = "not-found" });
return 1;
}
_output.WriteLine(value);
return 0;
}
/// <summary>
/// list: prints a JSON array of the safe metadata projection for every secret — name,
/// description, content type, KEK id, revision, tombstone flag, and update stamps. NEVER a value.
/// </summary>
/// <param name="includeDeleted">When <see langword="true"/>, tombstoned rows are included.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 on success.</returns>
public async Task<int> ListAsync(bool includeDeleted, CancellationToken ct)
{
IReadOnlyList<SecretMetadata> items = await _store.ListAsync(includeDeleted, ct).ConfigureAwait(false);
var projection = items.Select(m => new
{
name = m.Name.Value,
description = m.Description,
contentType = m.ContentType.ToString(),
kekId = m.KekId,
revision = m.Revision,
isDeleted = m.IsDeleted,
updatedUtc = m.UpdatedUtc,
updatedBy = m.UpdatedBy,
});
WriteJson(projection);
return 0;
}
/// <summary>
/// rm: soft-deletes (tombstones) <paramref name="name"/> and prints a JSON confirmation with a
/// <c>found</c> flag.
/// </summary>
/// <param name="name">The secret to tombstone.</param>
/// <param name="actor">Principal recorded as updated_by, if known.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>0 if a row was tombstoned; non-zero if no such row existed.</returns>
public async Task<int> RemoveAsync(string name, string? actor, CancellationToken ct)
{
var secretName = new SecretName(name);
bool removed = await _store.DeleteAsync(secretName, actor, ct).ConfigureAwait(false);
WriteJson(new { name = secretName.Value, action = "removed", found = removed });
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)
{
StoredSecret row = _cipher.Encrypt(name, value, contentType) with
{
Description = description,
CreatedBy = actor,
UpdatedBy = actor,
};
await _store.UpsertAsync(row, ct).ConfigureAwait(false);
}
/// <summary>Serializes <paramref name="value"/> to compact JSON on its own line.</summary>
private void WriteJson(object value) => _output.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
}