using System.Text.Json; using ZB.MOM.WW.Secrets.Abstractions; using ZB.MOM.WW.Secrets.Rotation; namespace ZB.MOM.WW.Secrets.Cli; /// /// The thin, front-end-agnostic command layer behind the secret CLI verbs /// (set / get / list / rm / rotate). Each verb is an /// -returning task (0 on success, non-zero on error) and writes ALL output to the /// injected — never to 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 /// , which intentionally prints the decrypted plaintext (see its remarks). /// 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; /// Creates the command set over the supplied core seams and output sink. /// The encrypted store (upsert / delete / list). /// The envelope cipher used to seal values on set / rotate. /// The read seam used by get to resolve current plaintext. /// Sink for all command output (JSON confirmations, listings, values). 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)); } /// /// set: seals 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. /// /// The secret name. /// The plaintext to seal. /// How consumers should interpret the plaintext. /// Optional human-readable description. /// Principal recorded as created_by / updated_by, if known. /// A token to cancel the operation. /// 0 on success. public async Task 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; } /// /// rotate: like , 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. /// /// The existing secret to rotate. /// The new plaintext to seal in place. /// How consumers should interpret the plaintext. /// Optional human-readable description. /// Principal recorded as updated_by, if known. /// A token to cancel the operation. /// 0 on success; non-zero if the secret does not exist. public async Task 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; } /// /// get: resolves and prints the current DECRYPTED plaintext for to the /// output sink. /// /// /// This deliberately exposes the secret value. 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 not-found /// error and returns non-zero. /// /// The secret to resolve. /// A token to cancel the operation. /// 0 when the value was printed; non-zero when the secret is absent. public async Task 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; } /// /// 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. /// /// When , tombstoned rows are included. /// A token to cancel the operation. /// 0 on success. public async Task ListAsync(bool includeDeleted, CancellationToken ct) { IReadOnlyList 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; } /// /// rm: soft-deletes (tombstones) and prints a JSON confirmation with a /// found flag. /// /// The secret to tombstone. /// Principal recorded as updated_by, if known. /// A token to cancel the operation. /// 0 if a row was tombstoned; non-zero if no such row existed. public async Task 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; } /// /// rewrap-all: rotates the master KEK by re-wrapping every row's DEK from /// onto — 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. /// /// The provider for the KEK rows are currently wrapped under. /// The provider for the KEK rows should be re-wrapped onto. /// A token to cancel the operation. /// 0 on success; non-zero if the rotation was rejected or aborted. public async Task 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; } } /// Seals a value under the cipher and upserts it, carrying description + actor stamps. 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); } /// Serializes to compact JSON on its own line. private void WriteJson(object value) => _output.WriteLine(JsonSerializer.Serialize(value, JsonOptions)); }