From 7ab1c861788fb93af1e89f380c8d49bc5f60521c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 15 Jul 2026 16:20:01 -0400 Subject: [PATCH] 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. --- docs/plans/2026-07-15-secrets-manager.md | 554 ++++++++++++++++++ .../2026-07-15-secrets-manager.md.tasks.json | 20 + 2 files changed, 574 insertions(+) create mode 100644 docs/plans/2026-07-15-secrets-manager.md create mode 100644 docs/plans/2026-07-15-secrets-manager.md.tasks.json diff --git a/docs/plans/2026-07-15-secrets-manager.md b/docs/plans/2026-07-15-secrets-manager.md new file mode 100644 index 0000000..b424a30 --- /dev/null +++ b/docs/plans/2026-07-15-secrets-manager.md @@ -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, ``, central package management). +- `Directory.Packages.props` — reuse the same `` 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 `0.1.0`. Copy `ZB.MOM.WW.Auth/Directory.Packages.props` verbatim and add a `` 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: ``, ``, ``. 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 ``. + +**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"`, `Exe`, `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 `` 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(() => 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 +{ + /// 32-byte AES-256 master key. Throws MasterKeyUnavailableException if unresolved. + ReadOnlyMemory GetMasterKey(); + /// Stable id of the active KEK (recorded per row for rotation legibility). + string KekId { get; } +} + +public interface ISecretCipher +{ + /// Envelope-encrypts plaintext, binding it to as AAD. + StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType); + /// Decrypts a row. Throws SecretDecryptionException on tag mismatch / unknown KEK. + string Decrypt(StoredSecret secret); +} + +public interface ISecretStore +{ + Task GetAsync(SecretName name, CancellationToken ct); + Task UpsertAsync(StoredSecret row, CancellationToken ct); + Task DeleteAsync(SecretName name, string? actor, CancellationToken ct); // soft delete + Task> ListAsync(bool includeDeleted, CancellationToken ct); + Task> GetManifestAsync(CancellationToken ct); + Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct); // LWW upsert (cluster) +} + +public interface ISecretResolver +{ + Task GetAsync(SecretName name, CancellationToken ct); +} + +/// No-op in core; ZB.MOM.WW.Secrets.Akka provides the cluster implementation (deferred). +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(); // 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(() => 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(() => 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(() => 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 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`, `TryAddSingleton` the connection factory (from `SqlitePath`), `IMasterKeyProvider` (via `MasterKeyProviderFactory`), `ISecretCipher` (`AesGcmEnvelopeCipher`), `ISecretStore` (`SqliteSecretStore`), `ISecretResolver` (`DefaultSecretResolver`), `ISecretReplicator` (`NoOpSecretReplicator`), the migrator, and `AddHostedService()`. `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 ` 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. diff --git a/docs/plans/2026-07-15-secrets-manager.md.tasks.json b/docs/plans/2026-07-15-secrets-manager.md.tasks.json new file mode 100644 index 0000000..8da284b --- /dev/null +++ b/docs/plans/2026-07-15-secrets-manager.md.tasks.json @@ -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" +}