baea6cc2e0
- new task-first quickstart (deployment seeding + KEK-lockout walkthroughs) - runbook: document the sixth ReferenceStatus (PresentUnverified) + quickstart link - README + shared-contract + umbrella CLAUDE.md secrets row updated for 0.3.0 console - CLI usage text now mentions the interactive console (was undiscoverable from --help)
114 lines
6.4 KiB
Markdown
114 lines
6.4 KiB
Markdown
# 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).
|