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.
This commit is contained in:
Joseph Doherty
2026-07-15 16:20:01 -04:00
parent 9df4f9bb84
commit 7ab1c86178
2 changed files with 574 additions and 0 deletions
+554
View File
@@ -0,0 +1,554 @@
# 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`](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; `PackageReference`s 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.**
```bash
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.**
```csharp
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).**
```csharp
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.**
```csharp
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:
```sql
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 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 + `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).
```bash
# 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.
@@ -0,0 +1,20 @@
{
"planPath": "docs/plans/2026-07-15-secrets-manager.md",
"tasks": [
{"id": 1, "subject": "Task 1: Scaffold solution + 4 projects + build props", "status": "pending"},
{"id": 2, "subject": "Task 2: Abstractions value types/enums/exceptions", "status": "pending", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: Abstractions seam interfaces", "status": "pending", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: AesGcmEnvelopeCipher", "status": "pending", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: Master-key providers + factory", "status": "pending", "blockedBy": [3]},
{"id": 6, "subject": "Task 6: SQLite factory + schema v1 + migrator", "status": "pending", "blockedBy": [3]},
{"id": 7, "subject": "Task 7: SqliteSecretStore CRUD/tombstone/manifest/LWW", "status": "pending", "blockedBy": [6]},
{"id": 8, "subject": "Task 8: DefaultSecretResolver (decrypt+cache+audit)", "status": "pending", "blockedBy": [4, 7]},
{"id": 9, "subject": "Task 9: ${secret:} config expander", "status": "pending", "blockedBy": [3]},
{"id": 10, "subject": "Task 10: SecretsOptions + AddZbSecrets DI + migration hosted service", "status": "pending", "blockedBy": [4, 5, 6, 7, 8, 9]},
{"id": 11, "subject": "Task 11: Ui RCL + authz policies + secrets list", "status": "pending", "blockedBy": [3]},
{"id": 12, "subject": "Task 12: Ui add/rotate/delete modal + gated reveal", "status": "pending", "blockedBy": [11]},
{"id": 13, "subject": "Task 13: Secrets.Cli commands", "status": "pending", "blockedBy": [10]},
{"id": 14, "subject": "Task 14: Pack + wire HistorianGateway + live verify", "status": "pending", "blockedBy": [10, 12, 13]}
],
"lastUpdated": "2026-07-15"
}