Files
scadaproj/components/secrets/shared-contract/ZB.MOM.WW.Secrets.md
T
Joseph Doherty baea6cc2e0 docs(secrets): interactive-console quickstart + discoverability sweep
- 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)
2026-07-19 14:55:53 -04:00

6.4 KiB

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

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

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.UiSecretsPage (@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

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

MasterKey.SourceEnvironment | 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.MigrateAsyncSecretReferenceExpander.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).