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.
33 KiB
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-shotCREATE ... IF NOT EXISTSschema +schema_versiontable, idempotentPRAGMA table_infocolumn guards — copy structure fromAuthSqliteConnectionFactory/SqliteAuthSchema/SqliteAuthStoreMigrator. - DI idiom:
AddZbSecrets(services, config, sectionPath)withTryAdd*singletons + a migrationIHostedService— copy structure fromApiKeyServiceCollectionExtensions. - Provider seam idiom:
IMasterKeyProvidermirrorsIApiKeyPepperProvider+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.
SecretContentTypeenum:Text, ConnectionString, Json, BinaryBase64.StoredSecretrecord: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.)SecretMetadatarecord: 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.SecretManifestEntryrecord:SecretName Name, long Revision, DateTimeOffset UpdatedUtc, bool IsDeleted(for cluster anti-entropy).SecretsExceptions.cs:MasterKeyUnavailableException,SecretDecryptionException,SecretNotFoundException,SecretStoreMigrationException— eachsealed, deriving fromInvalidOperationExceptionwith a(string message)ctor (mirrorAuthStoreMigrationException).
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) withAesGcm(dek, 16)using a fresh 12-byte nonce andassociatedData = 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 aStoredSecretwithRevision = 0, timestamps set by the store (leavedefault, store stamps),KekId = provider.KekId.Decrypt: unwrap DEK with masterKey (AAD = kekId); if the provider'sKekId != secret.KekId, throwSecretDecryptionException($"Row wrapped by unknown KEK '{secret.KekId}'."). Then decrypt the body with the DEK (AAD = name). Wrap everyAuthenticationTagMismatchException/CryptographicExceptioninSecretDecryptionException. Zero the DEK buffer in afinally(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= configuredSecrets:MasterKey:KekIdif 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. ThrowPlatformNotSupportedExceptionif not Windows.MasterKeyProviderFactory.Create(SecretsOptions)switch onMasterKey.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:
UpsertthenGetround-trips all columns; a secondUpsertof the same name overwrites in place and bumpsrevision(0 → 1) andupdated_utc.List(includeDeleted:false)returnsSecretMetadataonly (compile-time: type has no byte[] members) and excludes tombstoned rows.Deletesetsis_deleted=1+deleted_utc, bumps revision;Getstill returns the tombstone row butList(false)omits it.GetManifestreturns(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):
GetAsyncreturns decrypted plaintext for a present, non-deleted secret; caches so a second call doesn't re-hit the store; writes an audit event withOutcome.Successand the secret name but not the value (assert the auditDetailsJson/fields never contain the plaintext).- Missing or tombstoned secret → returns
nulland auditsOutcome.Failure/ not-found. - A rotate (store revision bump) invalidates the cache entry (resolver keys the cache by
(name, revision)or exposesInvalidate(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 test — AddZbSecrets 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 +AddSecretsAuthorizationext) - 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:
SecretEditoradd: fill name + masked value + content-type, submit → callsISecretCipher.Encrypt+ISecretStore.UpsertAsynconce; value inputtype="password".- Rotate: same editor pre-filled with name/description (no existing value shown), submit overwrites in place.
ConfirmDeleteModalis an in-page Theme modal (assert noIJSRuntimeconfirminterop — the component renders a.modalelement, not a JS dialog).RevealButton: whensecrets:revealgranted, click resolves + shows plaintext and the fakeIAuditWriterrecorded 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 toZB.MOM.WW.Secrets+.Ui) - Modify:
~/Desktop/HistorianGateway/.../Program.cs(callAddZbSecrets+ expand${secret:}before options validation + map the/admin/secretsUI + 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 CLIsecret set sql/historiangw/historian-password <pw>against the gateway's SQLite path. dotnet runthe gateway; hit/health/ready→ Healthy (proves the${secret:}password resolved into the live SQL connection).- Open
/admin/secretsin 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.Akkacluster replicator (design in §8 of the design doc; build when ScadaBridge/OtOpcUa adopt).- SQL Server
ISecretStoreprovider. components/secrets/normalization (spec / current-state ×3 / GAPS) + all-app adoption.RewrapAll(oldKek,newKek)KEK-rotation admin op + runbook (cipher/store already carrykek_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.