# Shared contract — `ZB.MOM.WW.Secrets`

The public API of the shared secrets library. Unlike most `shared-contract/` docs (which
propose an API to extract), this component's library is **already built and published** —
this records the **realized** surface (v0.1.2 on the `dohertj2-gitea` NuGet feed). Source
lives in-repo at [`ZB.MOM.WW.Secrets/`](../../../ZB.MOM.WW.Secrets/).

## Packages (mirrors `ZB.MOM.WW.Auth`'s shape)

| Package | Role | Depends on |
|---|---|---|
| `ZB.MOM.WW.Secrets.Abstractions` | Contracts only — interfaces, records, enums, exceptions | BCL + `DI.Abstractions` |
| `ZB.MOM.WW.Secrets` | Impl — cipher, master-key providers, SQLite store + migrator, resolver, `${secret:}` expander, `AddZbSecrets` | Abstractions, `Microsoft.Data.Sqlite`, `ZB.MOM.WW.Audit` |
| `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme` — `/admin/secrets` (list / add / rotate / delete / gated reveal) | Abstractions, `ZB.MOM.WW.Theme`, `Auth.AspNetCore` |
| `ZB.MOM.WW.Secrets.Cli` *(tool, not packed)* | `secret set / get / list / rm / rotate / rewrap-all`; plus a menu-driven **interactive console** (bare `secret` on a TTY) for deployment seeding + KEK-lockout recovery — see the [runbook](../../../ZB.MOM.WW.Secrets/docs/operations/interactive-console.md) | Secrets |
| `ZB.MOM.WW.Secrets.Akka` *(deferred — not built)* | Cluster replication of encrypted rows | Secrets, Akka.Cluster |

## Abstractions (contracts)

Interfaces: `IMasterKeyProvider`, `ISecretCipher`, `ISecretStore`, `ISecretResolver`,
`ISecretReplicator`, `ISecretCacheInvalidator`, `ISecretActorAccessor`.

```csharp
public interface IMasterKeyProvider          // 32-byte KEK; KekId identifies the active key
{
    ReadOnlyMemory<byte> GetMasterKey();
    string KekId { get; }
}

public interface ISecretStore                 // pluggable storage (SQLite default)
{
    Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct);
    Task UpsertAsync(StoredSecret row, CancellationToken ct);        // overwrite-in-place
    Task<bool> DeleteAsync(SecretName name, CancellationToken ct);   // soft delete (tombstone)
    Task<IReadOnlyList<SecretMetadata>> ListAsync(CancellationToken ct); // metadata only
    Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct);  // (name → revision)
    Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct);   // LWW upsert (cluster)
}

public interface ISecretResolver              // app-runtime read (cached, audited)
{
    Task<string?> GetAsync(SecretName name, CancellationToken ct);
}

public interface ISecretCacheInvalidator { /* invalidate on rotate/delete */ }
public interface ISecretReplicator { /* no-op default; Akka impl deferred */ }
public interface ISecretActorAccessor { /* supplies the audit actor from the host */ }
```

Types: `SecretName` (validated `readonly record struct` — rejects `..` path-traversal,
rooted/`//` segments, empty; fail-fast on `default`), `SecretContentType` enum
(`Text` | `ConnectionString` | `Json` | `BinaryBase64`), `StoredSecret` (the ciphertext
row), `SecretMetadata` (**no** `byte[]` members — safe to list/log), `SecretManifestEntry`.

Exceptions (all fail-closed): `MasterKeyUnavailableException`, `SecretDecryptionException`
(GCM tag mismatch / tamper / wrong KEK), `SecretNotFoundException`,
`SecretStoreMigrationException`.

## Implementation (core)

- **`AesGcmEnvelopeCipher`** (`…Crypto`) — per-write DEK, AES-256-GCM, AAD name-binding,
  DEK-wrap under the KEK, `CryptographicOperations.ZeroMemory` on DEK material.
- **Master-key providers** (`…MasterKey`) — `Environment` / `File` / `Dpapi` selected by
  `MasterKeyProviderFactory` from `MasterKeyOptions`; 32-byte validated; fail-closed.
- **`SqliteSecretStore`** + `SqliteSecretsStoreMigrator` + `SecretsSqliteConnectionFactory`
  (`…Sqlite`) — schema v1, WAL + `busy_timeout`, idempotent migration.
- **`DefaultSecretResolver`** — TTL cache; audits every resolve (Success/Failure) via
  `ZB.MOM.WW.Audit`; **fail-loud** on decrypt failure (audit Failure, then rethrow).
- **`SecretReferenceExpander`** (`…Configuration`) — `${secret:name}` expansion pre-host,
  fail-closed, skips `_`-prefixed comment keys.
- **`AddZbSecrets(config, sectionPath)`** (`…DependencyInjection`) — one call wires the
  provider + cipher + store + resolver (also exposed as `ISecretCacheInvalidator`) + no-op
  replicator + a startup migration hosted service. Binds `SecretsOptions`
  (`SqlitePath`, `MasterKey`, `RunMigrationsOnStartup`, `ResolveCacheTtl`).

## UI + authz

- `ZB.MOM.WW.Secrets.Ui` — `SecretsPage` (`@page "/admin/secrets"`) mounted via the host's
  `AddAdditionalAssemblies(SecretsPage.Assembly)`; `SecretsList`, `SecretEditor`,
  `ConfirmDeleteModal` (in-page Theme modal), `RevealButton`.
- `SecretsAuthorization.AddSecretsAuthorization()` adds two named policies:
  **`secrets:manage`** (`ManageRole`/`RevealRole`/`Administrator`) for CRUD and
  **`secrets:reveal`** (`RevealRole`/`Administrator`) for plaintext reveal. Role names are
  compared case-sensitively (Ordinal) by ASP.NET — the admin role is
  `nameof(CanonicalRole.Administrator)` to match the family's canonical vocabulary.

## Configuration shape

```json
"Secrets": {
  "SqlitePath": "app-secrets.db",
  "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" },
  "RunMigrationsOnStartup": true,
  "ResolveCacheTtl": "00:00:30"
}
```

`MasterKey.Source` ∈ `Environment` | `File` | `Dpapi`. The master key itself is **never**
committed — env var (containers), mounted key file (clustered pairs), or DPAPI (Windows).

## Consumer wiring (reference: HistorianGateway)

1. Pre-host: a throwaway `ServiceCollection().AddZbSecrets(...)` provider →
   `SqliteSecretsStoreMigrator.MigrateAsync` → `SecretReferenceExpander.ExpandConfigurationAsync`
   over `builder.Configuration`, **before** options bind/validation (scoped
   `#pragma warning disable ASP0000` for the deliberate throwaway container).
2. Runtime: `builder.Services.AddZbSecrets(...)` for the app's `ISecretResolver`.
3. UI: `Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization())` +
   `AddAdditionalAssemblies(SecretsPage.Assembly)`.
4. A config value becomes `"${secret:sql/<app>/<name>}"`; the value is seeded via the CLI or
   the `/admin/secrets` UI. **Live-proven** end-to-end 2026-07-16: a resolved `${secret:}`
   password authenticated a real read against the wonder historian (wrong secret →
   `HistorianAuthException`; correct → real samples).
