Files
scadaproj/docs/plans/2026-07-15-secrets-manager.md
Joseph Doherty 7ab1c86178 docs(plans): ZB.MOM.WW.Secrets implementation plan (14 tasks)
TDD, bite-sized tasks: scaffold -> abstractions -> envelope cipher + master-key
providers + sqlite store/migrator -> resolver + ${secret:} expander -> DI ->
Ui RCL (list/add/rotate/delete/gated reveal) -> CLI -> HistorianGateway consumer
+ live verify. Akka replicator / SQL-Server store / normalization deferred.
2026-07-15 16:20:01 -04:00

33 KiB
Raw Permalink Blame History

ZB.MOM.WW.Secrets Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.

Goal: Build ZB.MOM.WW.Secrets — a reusable, envelope-encrypted (AES-256-GCM) secrets manager library + Blazor UI + CLI for the ZB.MOM.WW.* SCADA family, with HistorianGateway wired as the reference consumer.

Architecture: Four packages mirroring ZB.MOM.WW.Auth's shape (.Abstractions / core / .Ui / .Cli). Secrets are stored in SQLite behind an ISecretStore seam; each value is encrypted with a per-write data key (DEK) that is itself wrapped by a master KEK resolved from a pluggable IMasterKeyProvider (env / file / DPAPI). Apps read plaintext at runtime via ISecretResolver and via a ${secret:name} config expander; operators manage secrets through a Theme-styled Blazor RCL with an audited, policy-gated reveal. Cluster-sync columns and an ISecretReplicator seam exist from day one, but the Akka replicator is deferred.

Tech Stack: .NET 10, System.Security.Cryptography.AesGcm, Microsoft.Data.Sqlite 10.0.7, Blazor RCL on ZB.MOM.WW.Theme, ZB.MOM.WW.Audit, ZB.MOM.WW.Auth.AspNetCore, xUnit + bUnit.

Design doc: 2026-07-15-secrets-manager-design.md

Location: scadaproj/ZB.MOM.WW.Secrets/plain files, NOT a nested git repo (do not git init; commit into the scadaproj repo). Reference: shared-libs-are-plain-files-not-nested-repos.

Conventions to copy verbatim from ZB.MOM.WW.Auth:

  • Directory.Build.props (net10.0, Nullable/ImplicitUsings enable, <Version>, central package management).
  • Directory.Packages.props — reuse the same <PackageVersion> pins (Sqlite 10.0.7, Extensions 10.0.7, xunit 2.9.3, bunit 1.40.0, Test.Sdk 17.14.1).
  • SQLite idiom: SecretsSqliteConnectionFactory (WAL + 5s busy_timeout), single-shot CREATE ... IF NOT EXISTS schema + schema_version table, idempotent PRAGMA table_info column guards — copy structure from AuthSqliteConnectionFactory / SqliteAuthSchema / SqliteAuthStoreMigrator.
  • DI idiom: AddZbSecrets(services, config, sectionPath) with TryAdd* singletons + a migration IHostedService — copy structure from ApiKeyServiceCollectionExtensions.
  • Provider seam idiom: IMasterKeyProvider mirrors IApiKeyPepperProvider + ConfigurationApiKeyPepperProvider.

Task 1: Scaffold solution, build props, and four project skeletons

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (foundation)

Files:

  • Create: ZB.MOM.WW.Secrets/Directory.Build.props
  • Create: ZB.MOM.WW.Secrets/Directory.Packages.props
  • Create: ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.slnx
  • Create: ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Abstractions/ZB.MOM.WW.Secrets.Abstractions.csproj
  • Create: ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets/ZB.MOM.WW.Secrets.csproj
  • Create: ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Ui/ZB.MOM.WW.Secrets.Ui.csproj
  • Create: ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Cli/ZB.MOM.WW.Secrets.Cli.csproj
  • Create: ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Tests/ZB.MOM.WW.Secrets.Tests.csproj
  • Create: ZB.MOM.WW.Secrets/tests/ZB.MOM.WW.Secrets.Ui.Tests/ZB.MOM.WW.Secrets.Ui.Tests.csproj

Step 1: Copy build props. Copy ZB.MOM.WW.Auth/Directory.Build.props verbatim but set <Version>0.1.0</Version>. Copy ZB.MOM.WW.Auth/Directory.Packages.props verbatim and add a <PackageVersion Include="bunit" Version="1.40.0" /> line (present in the Theme feed). The core lib consumes ZB.MOM.WW.Audit and ZB.MOM.WW.Theme / ZB.MOM.WW.Auth.AspNetCore as feed PackageReferences (they're published) — add pins: <PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />, <PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />, <PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.4" />. Confirm the Gitea feed is on the machine's nuget.config (it is — used by HistorianGateway).

Step 2: Abstractions csproj. Copy ZB.MOM.WW.Auth.Abstractions.csproj, rename PackageId/Description/URLs to ZB.MOM.WW.Secrets.Abstractions. Add <InternalsVisibleTo Include="ZB.MOM.WW.Secrets.Tests" />.

Step 3: Core csproj. Copy the ApiKeys csproj shape: ProjectReference to Abstractions; PackageReferences to Microsoft.Data.Sqlite, the Microsoft.Extensions.* set (DependencyInjection.Abstractions, Options, Options.ConfigurationExtensions, Configuration.Abstractions, Hosting.Abstractions), and ZB.MOM.WW.Audit. IsPackable=true, PackageId ZB.MOM.WW.Secrets. InternalsVisibleTo the test project.

Step 4: Ui csproj. Sdk="Microsoft.NET.Sdk.Razor", copy Theme csproj property shape (TreatWarningsAsErrors, GenerateDocumentationFile, NoWarn CS1591, FrameworkReference Microsoft.AspNetCore.App). ProjectReference Abstractions; PackageReference ZB.MOM.WW.Theme + ZB.MOM.WW.Auth.AspNetCore. InternalsVisibleTo Ui.Tests.

Step 5: Cli csproj. Sdk="Microsoft.NET.Sdk", <OutputType>Exe</OutputType>, IsPackable=false. ProjectReference core.

Step 6: Test csprojs. Core tests: copy the Auth test csproj (xunit, Test.Sdk, coverlet, Xunit.SkippableFact for the DPAPI-Windows-only tests) + ProjectReferences to core + Abstractions. Ui tests: copy the Theme test csproj (adds bunit) + ProjectReference to Ui.

Step 7: slnx. Create ZB.MOM.WW.Secrets.slnx listing all six projects (copy the <Solution> XML shape from ZB.MOM.WW.Auth/ZB.MOM.WW.Auth.slnx).

Step 8: Verify build. Run: cd ZB.MOM.WW.Secrets && dotnet build ZB.MOM.WW.Secrets.slnx Expected: build succeeds, 0 warnings (projects are empty skeletons; add a placeholder namespace ZB.MOM.WW.Secrets; file per project if the Razor/Exe SDK complains about no compilable input).

Step 9: Commit.

git add ZB.MOM.WW.Secrets/
git commit -m "feat(secrets): scaffold ZB.MOM.WW.Secrets solution + 4 projects"

Task 2: Abstractions — value types, enums, exceptions

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (blocks all core work)

Files:

  • Create: src/ZB.MOM.WW.Secrets.Abstractions/SecretName.cs
  • Create: src/ZB.MOM.WW.Secrets.Abstractions/SecretContentType.cs
  • Create: src/ZB.MOM.WW.Secrets.Abstractions/StoredSecret.cs
  • Create: src/ZB.MOM.WW.Secrets.Abstractions/SecretMetadata.cs
  • Create: src/ZB.MOM.WW.Secrets.Abstractions/SecretManifestEntry.cs
  • Create: src/ZB.MOM.WW.Secrets.Abstractions/SecretsExceptions.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/SecretNameTests.cs

Step 1: Write failing test for SecretName normalization.

using ZB.MOM.WW.Secrets.Abstractions;
using Xunit;

public class SecretNameTests
{
    [Theory]
    [InlineData("sql/historiangw/historian-password", "sql/historiangw/historian-password")]
    [InlineData("  SQL/Foo  ", "sql/foo")] // trimmed + lowercased
    public void Normalizes(string input, string expected)
        => Assert.Equal(expected, new SecretName(input).Value);

    [Theory]
    [InlineData("")]
    [InlineData("  ")]
    [InlineData("bad name with spaces")]
    [InlineData("../escape")]
    public void RejectsInvalid(string input)
        => Assert.Throws<ArgumentException>(() => new SecretName(input));
}

Run: dotnet test tests/ZB.MOM.WW.Secrets.Tests --filter SecretNameTests → FAIL (type missing).

Step 2: Implement SecretName as a readonly record struct wrapping a validated string. Rules: trim, lowercase-invariant; allow [a-z0-9._/-] segments; reject empty, whitespace, .., and any char outside the allow-list (throw ArgumentException). Expose string Value and override string ToString() => Value, plus implicit operator string.

Step 3: Implement the remaining types.

  • SecretContentType enum: Text, ConnectionString, Json, BinaryBase64.
  • StoredSecret record: SecretName Name, string? Description, SecretContentType ContentType, byte[] Ciphertext, Nonce, Tag, WrappedDek, WrapNonce, WrapTag, string KekId, long Revision, bool IsDeleted, DateTimeOffset? DeletedUtc, DateTimeOffset CreatedUtc, UpdatedUtc, string? CreatedBy, UpdatedBy. (This is the encrypted row — never contains plaintext.)
  • SecretMetadata record: the non-ciphertext subset returned to the UI list (Name, Description, ContentType, KekId, Revision, IsDeleted, CreatedUtc, UpdatedUtc, CreatedBy, UpdatedBy). No byte[] fields — this is the type the UI binds so plaintext/ciphertext can't leak into markup.
  • SecretManifestEntry record: SecretName Name, long Revision, DateTimeOffset UpdatedUtc, bool IsDeleted (for cluster anti-entropy).
  • SecretsExceptions.cs: MasterKeyUnavailableException, SecretDecryptionException, SecretNotFoundException, SecretStoreMigrationException — each sealed, deriving from InvalidOperationException with a (string message) ctor (mirror AuthStoreMigrationException).

Step 4: Run the test → PASS.

Step 5: Commit. git commit -m "feat(secrets): abstractions value types, enums, exceptions"


Task 3: Abstractions — seam interfaces

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 2 is a prerequisite; independent of Tasks 4-14 file-wise (interfaces only)

Files:

  • Create: src/ZB.MOM.WW.Secrets.Abstractions/ISecretCipher.cs
  • Create: src/ZB.MOM.WW.Secrets.Abstractions/IMasterKeyProvider.cs
  • Create: src/ZB.MOM.WW.Secrets.Abstractions/ISecretStore.cs
  • Create: src/ZB.MOM.WW.Secrets.Abstractions/ISecretResolver.cs
  • Create: src/ZB.MOM.WW.Secrets.Abstractions/ISecretReplicator.cs

Step 1: Define interfaces (no tests — pure contracts; XML-doc every member).

public interface IMasterKeyProvider
{
    /// <summary>32-byte AES-256 master key. Throws MasterKeyUnavailableException if unresolved.</summary>
    ReadOnlyMemory<byte> GetMasterKey();
    /// <summary>Stable id of the active KEK (recorded per row for rotation legibility).</summary>
    string KekId { get; }
}

public interface ISecretCipher
{
    /// <summary>Envelope-encrypts plaintext, binding it to <paramref name="name"/> as AAD.</summary>
    StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType);
    /// <summary>Decrypts a row. Throws SecretDecryptionException on tag mismatch / unknown KEK.</summary>
    string Decrypt(StoredSecret secret);
}

public interface ISecretStore
{
    Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct);
    Task UpsertAsync(StoredSecret row, CancellationToken ct);
    Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct); // soft delete
    Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct);
    Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct);
    Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct); // LWW upsert (cluster)
}

public interface ISecretResolver
{
    Task<string?> GetAsync(SecretName name, CancellationToken ct);
}

/// <summary>No-op in core; ZB.MOM.WW.Secrets.Akka provides the cluster implementation (deferred).</summary>
public interface ISecretReplicator
{
    Task PublishAsync(StoredSecret row, CancellationToken ct);
}

Step 2: Build the Abstractions project → succeeds, 0 warnings. Run: dotnet build src/ZB.MOM.WW.Secrets.Abstractions

Step 3: Commit. git commit -m "feat(secrets): abstractions seam interfaces"


Task 4: Core — AesGcmEnvelopeCipher

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 5, Task 6 (distinct files)

Files:

  • Create: src/ZB.MOM.WW.Secrets/Crypto/AesGcmEnvelopeCipher.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/Crypto/AesGcmEnvelopeCipherTests.cs

Step 1: Write failing tests.

public class AesGcmEnvelopeCipherTests
{
    private static IMasterKeyProvider Kek(string id = "k1")
    {
        var key = new byte[32]; RandomNumberGenerator.Fill(key);
        var m = new Mock<IMasterKeyProvider>(); // or a tiny inline fake
        m.Setup(x => x.GetMasterKey()).Returns(key);
        m.Setup(x => x.KekId).Returns(id);
        return m.Object;
    }

    [Fact]
    public void RoundTrips()
    {
        var c = new AesGcmEnvelopeCipher(Kek());
        var row = c.Encrypt(new("sql/foo"), "hunter2", SecretContentType.Text);
        Assert.Equal("hunter2", c.Decrypt(row));
        Assert.Equal("k1", row.KekId);
        Assert.DoesNotContain("hunter2"u8.ToArray(), row.Ciphertext); // not plaintext
    }

    [Fact]
    public void TamperedCiphertextThrows()
    {
        var c = new AesGcmEnvelopeCipher(Kek());
        var row = c.Encrypt(new("sql/foo"), "hunter2", SecretContentType.Text);
        row.Ciphertext[0] ^= 0xFF;
        Assert.Throws<SecretDecryptionException>(() => c.Decrypt(row));
    }

    [Fact]
    public void AadBindingRejectsRenamedRow()
    {
        var c = new AesGcmEnvelopeCipher(Kek());
        var row = c.Encrypt(new("sql/foo"), "hunter2", SecretContentType.Text);
        var renamed = row with { Name = new("sql/bar") };
        Assert.Throws<SecretDecryptionException>(() => c.Decrypt(renamed));
    }

    [Fact]
    public void WrongKekThrows()
    {
        var enc = new AesGcmEnvelopeCipher(Kek("k1"));
        var row = enc.Encrypt(new("sql/foo"), "hunter2", SecretContentType.Text);
        var dec = new AesGcmEnvelopeCipher(Kek("k2")); // different key material
        Assert.Throws<SecretDecryptionException>(() => dec.Decrypt(row));
    }
}

(Use a hand-written FakeMasterKeyProvider rather than Moq — the family avoids Moq; grep ZB.MOM.WW.Auth.Tests for the fake pattern.) Run: dotnet test ... --filter AesGcmEnvelopeCipherTests → FAIL.

Step 2: Implement AesGcmEnvelopeCipher.

  • Ctor takes IMasterKeyProvider.
  • Encrypt: generate 32-byte DEK (RandomNumberGenerator.Fill). Encrypt plaintext (UTF-8) with AesGcm(dek, 16) using a fresh 12-byte nonce and associatedData = UTF8(name.Value) → ciphertext + 16-byte tag. Then wrap the DEK: AesGcm(masterKey, 16) with a fresh 12-byte wrap-nonce, associatedData = UTF8(kekId) → wrapped_dek + wrap_tag. Return a StoredSecret with Revision = 0, timestamps set by the store (leave default, store stamps), KekId = provider.KekId.
  • Decrypt: unwrap DEK with masterKey (AAD = kekId); if the provider's KekId != secret.KekId, throw SecretDecryptionException($"Row wrapped by unknown KEK '{secret.KekId}'."). Then decrypt the body with the DEK (AAD = name). Wrap every AuthenticationTagMismatchException / CryptographicException in SecretDecryptionException. Zero the DEK buffer in a finally (CryptographicOperations.ZeroMemory).

Step 3: Run tests → PASS.

Step 4: Commit. git commit -m "feat(secrets): AES-256-GCM envelope cipher with AAD binding"


Task 5: Core — master-key providers + selection

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 4, Task 6

Files:

  • Create: src/ZB.MOM.WW.Secrets/MasterKey/EnvironmentMasterKeyProvider.cs
  • Create: src/ZB.MOM.WW.Secrets/MasterKey/FileMasterKeyProvider.cs
  • Create: src/ZB.MOM.WW.Secrets/MasterKey/DpapiMasterKeyProvider.cs
  • Create: src/ZB.MOM.WW.Secrets/MasterKey/MasterKeyProviderFactory.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/MasterKey/MasterKeyProviderTests.cs

Step 1: Write failing tests — env provider reads base64-32-bytes and exposes a KekId (SHA-256 prefix of the key, or a configured id); missing/short key throws MasterKeyUnavailableException; file provider reads a key file; factory selects by Secrets:MasterKey:Source (Environment|File|Dpapi). DPAPI test uses [SkippableFact] + Skip.IfNot(OperatingSystem.IsWindows()). Run → FAIL.

Step 2: Implement.

  • Base helper: validate decoded key length == 32 else MasterKeyUnavailableException. KekId = configured Secrets:MasterKey:KekId if set, else "sha256:" + Convert.ToHexString(SHA256(key))[..12] (deterministic, non-secret).
  • EnvironmentMasterKeyProvider(string envVarName): Environment.GetEnvironmentVariable, base64-decode.
  • FileMasterKeyProvider(string path): read all bytes; accept raw 32 bytes or base64 text.
  • DpapiMasterKeyProvider(string path): [SupportedOSPlatform("windows")], ProtectedData.Unprotect. Throw PlatformNotSupportedException if not Windows.
  • MasterKeyProviderFactory.Create(SecretsOptions) switch on MasterKey.Source.

Step 3: Run → PASS (DPAPI test skips on macOS).

Step 4: Commit. git commit -m "feat(secrets): pluggable master-key providers (env/file/dpapi)"


Task 6: Core — SQLite connection factory, schema, migrator

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: Task 4, Task 5

Files:

  • Create: src/ZB.MOM.WW.Secrets/Sqlite/SecretsSqliteConnectionFactory.cs
  • Create: src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsSchema.cs
  • Create: src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretsStoreMigrator.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretsStoreMigratorTests.cs

Step 1: Write failing test — migrate a temp-file DB twice (idempotent), assert schema_version.version == 1 and the secret table exists with the expected columns via PRAGMA table_info; assert an on-disk version > CurrentVersion throws SecretStoreMigrationException. Run → FAIL.

Step 2: Copy SecretsSqliteConnectionFactory verbatim from AuthSqliteConnectionFactory (WAL + 5s busy timeout, Directory.CreateDirectory, pooling).

Step 3: Write SqliteSecretsSchema. CurrentVersion = 1. Tables: schema_version (same as Auth) and:

CREATE TABLE IF NOT EXISTS secret (
    name          TEXT PRIMARY KEY,
    description   TEXT NULL,
    content_type  TEXT NOT NULL,
    ciphertext    BLOB NOT NULL,
    nonce         BLOB NOT NULL,
    tag           BLOB NOT NULL,
    wrapped_dek   BLOB NOT NULL,
    wrap_nonce    BLOB NOT NULL,
    wrap_tag      BLOB NOT NULL,
    kek_id        TEXT NOT NULL,
    revision      INTEGER NOT NULL DEFAULT 0,
    is_deleted    INTEGER NOT NULL DEFAULT 0,
    deleted_utc   TEXT NULL,
    created_utc   TEXT NOT NULL,
    updated_utc   TEXT NOT NULL,
    created_by    TEXT NULL,
    updated_by    TEXT NULL
);
CREATE INDEX IF NOT EXISTS ix_secret_updated_utc ON secret (updated_utc);

Step 4: Copy SqliteSecretsStoreMigrator from SqliteAuthStoreMigrator (same serializable-transaction, version-read, refuse-newer, stamp-version structure). Since schema is v1, no additive ALTER migrations yet — leave the EnsureColumn helper in place (unused) as the documented forward-migration hook.

Step 5: Run → PASS.

Step 6: Commit. git commit -m "feat(secrets): sqlite connection factory + schema v1 + migrator"


Task 7: Core — SqliteSecretStore (CRUD, tombstone, manifest, LWW apply)

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends on Task 6)

Files:

  • Create: src/ZB.MOM.WW.Secrets/Sqlite/SqliteSecretStore.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/Sqlite/SqliteSecretStoreTests.cs

Step 1: Write failing tests against a migrated temp DB:

  • Upsert then Get round-trips all columns; a second Upsert of the same name overwrites in place and bumps revision (0 → 1) and updated_utc.
  • List(includeDeleted:false) returns SecretMetadata only (compile-time: type has no byte[] members) and excludes tombstoned rows.
  • Delete sets is_deleted=1 + deleted_utc, bumps revision; Get still returns the tombstone row but List(false) omits it.
  • GetManifest returns (name, revision, updatedUtc, isDeleted) for all rows.
  • ApplyReplicated: applies an incoming row only when its (updatedUtc, revision) is newer (LWW); a stale incoming row is ignored. Run → FAIL.

Step 2: Implement SqliteSecretStore(SecretsSqliteConnectionFactory). Parameterized commands only. UpsertAsync uses INSERT ... ON CONFLICT(name) DO UPDATE SET ... , revision = revision + 1, updated_utc = $now, preserving created_utc/created_by on update (only set on insert). Timestamps stamped here (DateTimeOffset.UtcNow.ToString("O")). DeleteAsync = UPDATE set tombstone + bump revision. ListAsync selects the metadata columns only. ApplyReplicatedAsync reads the current (revision, updated_utc) in the same transaction and writes only if incoming is strictly newer by (updated_utc, revision).

Step 3: Run → PASS.

Step 4: Commit. git commit -m "feat(secrets): sqlite secret store with overwrite-in-place + tombstones + LWW"


Task 8: Core — DefaultSecretResolver (decrypt + cache + audit)

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (depends on Tasks 4, 7)

Files:

  • Create: src/ZB.MOM.WW.Secrets/DefaultSecretResolver.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/DefaultSecretResolverTests.cs

Step 1: Write failing tests (fakes for ISecretStore, ISecretCipher, IAuditWriter):

  • GetAsync returns decrypted plaintext for a present, non-deleted secret; caches so a second call doesn't re-hit the store; writes an audit event with Outcome.Success and the secret name but not the value (assert the audit DetailsJson/fields never contain the plaintext).
  • Missing or tombstoned secret → returns null and audits Outcome.Failure / not-found.
  • A rotate (store revision bump) invalidates the cache entry (resolver keys the cache by (name, revision) or exposes Invalidate(name) called by the admin path). Run → FAIL.

Step 2: Implement. Constructor (ISecretStore, ISecretCipher, IAuditWriter, IClock?). Short-lived MemoryCache-style dictionary keyed by name with the row's revision stored alongside; on hit, verify the store's current revision (cheap manifest check) or use a small TTL — choose TTL (e.g. 30s) to avoid a store round-trip per resolve, documented. Every resolve emits a ZB.MOM.WW.Audit.AuditEvent (action secret.resolve, actor from an ISecretActorAccessor if present else "system"). Never log/audit the plaintext.

Step 3: Run → PASS.

Step 4: Commit. git commit -m "feat(secrets): default resolver with cache + audit (never logs plaintext)"


Task 9: Core — ${secret:name} config expander

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 8 (distinct files)

Files:

  • Create: src/ZB.MOM.WW.Secrets/Configuration/SecretReferenceExpander.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/Configuration/SecretReferenceExpanderTests.cs

Step 1: Write failing tests:

  • Expands Password=${secret:sql/foo} in a bound value using a fake resolver → substituted plaintext.
  • Multiple tokens in one string all expand.
  • A referenced-but-missing secret throws (fail-closed) with a message naming the token.
  • A string with no token is returned unchanged. Run → FAIL.

Step 2: Implement a static/sealed SecretReferenceExpander(ISecretResolver) with Task<string> ExpandAsync(string raw, CancellationToken) using a compiled Regex(@"\$\{secret:([^}]+)\}"). For each match, resolve; if null → throw SecretNotFoundException. Provide ExpandConfigurationAsync(IConfigurationRoot) that walks config KVs and rewrites in-memory (via an in-memory overlay provider) — this is what the DI hook calls at startup before options validation.

Step 3: Run → PASS.

Step 4: Commit. git commit -m "feat(secrets): \${secret:} config expander (fail-closed)"


Task 10: Core — SecretsOptions + AddZbSecrets DI + migration hosted service

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on Tasks 4-9)

Files:

  • Create: src/ZB.MOM.WW.Secrets/SecretsOptions.cs
  • Create: src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsServiceCollectionExtensions.cs
  • Create: src/ZB.MOM.WW.Secrets/DependencyInjection/SecretsMigrationHostedService.cs
  • Create: src/ZB.MOM.WW.Secrets/DependencyInjection/NoOpSecretReplicator.cs
  • Test: tests/ZB.MOM.WW.Secrets.Tests/DependencyInjection/AddZbSecretsTests.cs

Step 1: Write failing testAddZbSecrets on a ServiceCollection + in-memory config resolves ISecretResolver, ISecretStore, IMasterKeyProvider, ISecretCipher, ISecretReplicator (no-op); the migrator hosted service is registered; missing master key config surfaces as MasterKeyUnavailableException when the provider is first used (fail-closed). Run → FAIL.

Step 2: Implement SecretsOptions: SqlitePath, nested MasterKeyOptions MasterKey { Source, EnvVarName, FilePath, KekId }, bool RunMigrationsOnStartup, TimeSpan ResolveCacheTtl, bool RevealEnabled. Implement AddZbSecrets(services, config, sectionPath) copying the ApiKeyServiceCollectionExtensions structure: Configure<SecretsOptions>, TryAddSingleton the connection factory (from SqlitePath), IMasterKeyProvider (via MasterKeyProviderFactory), ISecretCipher (AesGcmEnvelopeCipher), ISecretStore (SqliteSecretStore), ISecretResolver (DefaultSecretResolver), ISecretReplicator (NoOpSecretReplicator), the migrator, and AddHostedService<SecretsMigrationHostedService>(). NoOpSecretReplicator.PublishAsync = Task.CompletedTask (the seam the deferred Akka package replaces).

Step 3: Run → PASS. Build the whole core project 0-warning.

Step 4: Commit. git commit -m "feat(secrets): SecretsOptions + AddZbSecrets DI + migration hosted service"


Task 11: UI — RCL project, authz policies, secrets list page

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 13 (CLI, distinct project)

Files:

  • Create: src/ZB.MOM.WW.Secrets.Ui/SecretsAuthorization.cs (policy names + AddSecretsAuthorization ext)
  • Create: src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor
  • Create: src/ZB.MOM.WW.Secrets.Ui/Pages/SecretsPage.razor (@page "/admin/secrets")
  • Create: src/ZB.MOM.WW.Secrets.Ui/_Imports.razor
  • Test: tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretsListTests.cs

Step 1: Write failing bUnit test — render SecretsList with a fake ISecretStore returning two SecretMetadata rows; assert the table shows names/descriptions/updated but the rendered markup contains no ciphertext/plaintext (there's nothing secret to leak since metadata has no byte[] — assert the "reveal" button is absent when the secrets:reveal policy is not granted). Run → FAIL.

Step 2: Implement. SecretsAuthorization: const string ManagePolicy = "secrets:manage", RevealPolicy = "secrets:reveal"; AddSecretsAuthorization(this AuthorizationOptions) registers both (require an authenticated user + a claim/role — reuse ZbClaimTypes). SecretsList.razor uses Theme TechCard/StatusPill, injects ISecretStore, calls ListAsync(false), binds SecretMetadata. SecretsPage.razor wraps it in the Theme shell and is [Authorize(Policy = SecretsAuthorization.ManagePolicy)].

Step 3: Run → PASS.

Step 4: Commit. git commit -m "feat(secrets-ui): RCL + authz policies + metadata-only secrets list"


Task 12: UI — add / rotate / delete (Theme modal) + gated reveal

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (depends on Task 11)

Files:

  • Create: src/ZB.MOM.WW.Secrets.Ui/Components/SecretEditor.razor (add + rotate)
  • Create: src/ZB.MOM.WW.Secrets.Ui/Components/ConfirmDeleteModal.razor
  • Create: src/ZB.MOM.WW.Secrets.Ui/Components/RevealButton.razor
  • Modify: src/ZB.MOM.WW.Secrets.Ui/Components/SecretsList.razor (wire actions)
  • Test: tests/ZB.MOM.WW.Secrets.Ui.Tests/SecretEditorTests.cs
  • Test: tests/ZB.MOM.WW.Secrets.Ui.Tests/RevealButtonTests.cs

Step 1: Write failing bUnit tests:

  • SecretEditor add: fill name + masked value + content-type, submit → calls ISecretCipher.Encrypt + ISecretStore.UpsertAsync once; value input type="password".
  • Rotate: same editor pre-filled with name/description (no existing value shown), submit overwrites in place.
  • ConfirmDeleteModal is an in-page Theme modal (assert no IJSRuntime confirm interop — the component renders a .modal element, not a JS dialog).
  • RevealButton: when secrets:reveal granted, click resolves + shows plaintext and the fake IAuditWriter recorded a reveal event; when not granted, the button isn't rendered. Run → FAIL.

Step 2: Implement the three components with Theme TechField/TechButton/TechCard. Reveal uses ISecretResolver + writes an audit event via IAuditWriter (actor from the authenticated user). Delete calls ISecretStore.DeleteAsync. No window.confirm/alert — the modal is pure Blazor state (browser-dialog hazard per repo guidance).

Step 3: Run → PASS.

Step 4: Commit. git commit -m "feat(secrets-ui): add/rotate/delete modal + audited gated reveal"


Task 13: CLI — secret set/get/list/rm/rotate

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 11 (distinct project)

Files:

  • Create: src/ZB.MOM.WW.Secrets.Cli/Program.cs
  • Create: src/ZB.MOM.WW.Secrets.Cli/SecretCommands.cs
  • Create: src/ZB.MOM.WW.Secrets.Cli/appsettings.json
  • Test: tests/ZB.MOM.WW.Secrets.Tests/Cli/SecretCommandsTests.cs

Step 1: Write failing tests driving SecretCommands (thin layer over ISecretStore + ISecretCipher + ISecretResolver, DI'd from config) against a temp DB: set then get round-trips; list prints metadata JSON (no value); rm tombstones; rotate overwrites in place. Model on ApiKeyAdminCommands. Run → FAIL.

Step 2: Implement. Program.cs builds a host with AddZbSecrets, parses argv (mirror the apikey CLI arg style), dispatches to SecretCommands. JSON output for list/get (get prints the value to stdout only — document the exposure). Master key sourced the same way as the app (env/file) so the CLI and app agree.

Step 3: Run → PASS. dotnet run --project src/ZB.MOM.WW.Secrets.Cli -- list against a temp DB prints [].

Step 4: Commit. git commit -m "feat(secrets-cli): set/get/list/rm/rotate commands"


Task 14: Pack + wire HistorianGateway reference consumer

Classification: high-risk Estimated implement time: ~5 min Parallelizable with: none (final integration)

Files:

  • Create: ZB.MOM.WW.Secrets/README.md
  • Modify: ~/Desktop/HistorianGateway/.../Server.csproj (add PackageReferences to ZB.MOM.WW.Secrets + .Ui)
  • Modify: ~/Desktop/HistorianGateway/.../Program.cs (call AddZbSecrets + expand ${secret:} before options validation + map the /admin/secrets UI + register authz policies)
  • Modify: ~/Desktop/HistorianGateway/.../appsettings.json (Secrets section; rewrite the two SQL passwords as ${secret:...})

Step 0 (coordination): This modifies a sibling repo (historiangw), a separate git repo. Per isolate-git-when-agents-share-a-repo, do this on a branch there. Pack the four scadaproj packages to the local Gitea feed first (dotnet pack -c Release each packable project; push to the dohertj2-gitea feed) so HistorianGateway consumes them as feed PackageReferences, matching how it already consumes ZB.MOM.WW.*. Verify the feed doesn't 404 before wiring (component-status-claims-are-optimistic).

Step 1: Write the lib README.md (purpose, AddZbSecrets usage, ${secret:} syntax, master-key config, the shared-KEK cluster requirement).

Step 2: In HistorianGateway on a new branch: add the two PackageReferences; in Program.cs call builder.Services.AddZbSecrets(builder.Configuration, "Secrets"), run SecretReferenceExpander.ExpandConfigurationAsync on the config root before ValidateOnStart options run, register AddSecretsAuthorization, and map the SecretsPage route into the existing Theme dashboard. Set Secrets:MasterKey:Source=Environment, EnvVarName=ZB_SECRETS_MASTER_KEY.

Step 3: Rewrite ConnectionStrings/historian + Galaxy SQL passwords in appsettings.json as ${secret:sql/historiangw/historian-password} and ${secret:sql/historiangw/galaxy-password}. Document the dev fallback (if no master key → fail-closed, so dev sets the env var + seeds via CLI).

Step 4: Verify end-to-end (this is the @verify gate — exercise the real flow, not just tests):

  • Set ZB_SECRETS_MASTER_KEY (base64 32 bytes), run the CLI secret set sql/historiangw/historian-password <pw> against the gateway's SQLite path.
  • dotnet run the gateway; hit /health/ready → Healthy (proves the ${secret:} password resolved into the live SQL connection).
  • Open /admin/secrets in the browser → the two secrets list (metadata only); reveal (with a reveal-policy user) shows the value and writes an audit row.

Step 5: Commit (in each repo).

# scadaproj
git add ZB.MOM.WW.Secrets/README.md && git commit -m "docs(secrets): library README"
# historiangw (separate repo, branch)
git commit -am "feat: adopt ZB.MOM.WW.Secrets for SQL passwords + mount secrets UI"

Deferred / follow-on (NOT in this plan)

  • ZB.MOM.WW.Secrets.Akka cluster replicator (design in §8 of the design doc; build when ScadaBridge/OtOpcUa adopt).
  • SQL Server ISecretStore provider.
  • components/secrets/ normalization (spec / current-state ×3 / GAPS) + all-app adoption.
  • RewrapAll(oldKek,newKek) KEK-rotation admin op + runbook (cipher/store already carry kek_id; add the walk when rotation is first needed).

Testing summary

Per-task TDD (xUnit for core/CLI, bUnit for UI). Whole-suite gate before Task 14: cd ZB.MOM.WW.Secrets && dotnet test ZB.MOM.WW.Secrets.slnx → all green, 0 warnings, cross-platform (DPAPI tests skip on macOS). Task 14 adds the live end-to-end verification against HistorianGateway.