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.
6.2 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 |
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.ZeroMemoryon DEK material.- Master-key providers (
…MasterKey) —Environment/File/Dpapiselected byMasterKeyProviderFactoryfromMasterKeyOptions; 32-byte validated; fail-closed. SqliteSecretStore+SqliteSecretsStoreMigrator+SecretsSqliteConnectionFactory(…Sqlite) — schema v1, WAL +busy_timeout, idempotent migration.DefaultSecretResolver— TTL cache; audits every resolve (Success/Failure) viaZB.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 asISecretCacheInvalidator) + no-op replicator + a startup migration hosted service. BindsSecretsOptions(SqlitePath,MasterKey,RunMigrationsOnStartup,ResolveCacheTtl).
UI + authz
ZB.MOM.WW.Secrets.Ui—SecretsPage(@page "/admin/secrets") mounted via the host'sAddAdditionalAssemblies(SecretsPage.Assembly);SecretsList,SecretEditor,ConfirmDeleteModal(in-page Theme modal),RevealButton.SecretsAuthorization.AddSecretsAuthorization()adds two named policies:secrets:manage(ManageRole/RevealRole/Administrator) for CRUD andsecrets:reveal(RevealRole/Administrator) for plaintext reveal. Role names are compared case-sensitively (Ordinal) by ASP.NET — the admin role isnameof(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.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)
- Pre-host: a throwaway
ServiceCollection().AddZbSecrets(...)provider →SqliteSecretsStoreMigrator.MigrateAsync→SecretReferenceExpander.ExpandConfigurationAsyncoverbuilder.Configuration, before options bind/validation (scoped#pragma warning disable ASP0000for the deliberate throwaway container). - Runtime:
builder.Services.AddZbSecrets(...)for the app'sISecretResolver. - UI:
Configure<AuthorizationOptions>(o => o.AddSecretsAuthorization())+AddAdditionalAssemblies(SecretsPage.Assembly). - A config value becomes
"${secret:sql/<app>/<name>}"; the value is seeded via the CLI or the/admin/secretsUI. 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).