docs(components): normalize Secrets component + index the shared lib
Add components/secrets/ (SPEC, realized shared-contract, code-verified current-state for OtOpcUa/mxaccessgw/ScadaBridge, GAPS adoption backlog) and register the Secrets row in CLAUDE.md + components/README.md. The ZB.MOM.WW.Secrets lib is built + published 0.1.2 + reference-consumer-proven (HistorianGateway, live vs the wonder historian); per-app adoption is the tracked follow-on.
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
# 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` | 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).
|
||||
Reference in New Issue
Block a user