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

555 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.