# 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.
