diff --git a/ZB.MOM.WW.Secrets/Directory.Build.props b/ZB.MOM.WW.Secrets/Directory.Build.props index c4755a6..3d9b855 100644 --- a/ZB.MOM.WW.Secrets/Directory.Build.props +++ b/ZB.MOM.WW.Secrets/Directory.Build.props @@ -7,6 +7,12 @@ latest 0.1.0 true + README.md + + + + + diff --git a/ZB.MOM.WW.Secrets/README.md b/ZB.MOM.WW.Secrets/README.md new file mode 100644 index 0000000..4a7ec36 --- /dev/null +++ b/ZB.MOM.WW.Secrets/README.md @@ -0,0 +1,67 @@ +# ZB.MOM.WW.Secrets + +A reusable, envelope-encrypted **secrets manager** for the `ZB.MOM.WW.*` SCADA family: +store secrets (SQL passwords, API tokens, connection strings) encrypted at rest and get the +plaintext back on demand — from application code, from configuration, or from an operator UI. + +## Packages + +| Package | Purpose | +|---|---| +| `ZB.MOM.WW.Secrets.Abstractions` | Contracts only (`ISecretStore`, `ISecretResolver`, `IMasterKeyProvider`, `ISecretCipher`, `ISecretReplicator`, `ISecretCacheInvalidator`, `ISecretActorAccessor`), value types, exceptions. Dependency-light. | +| `ZB.MOM.WW.Secrets` | Implementation: AES-256-GCM envelope cipher, env/file/DPAPI master-key providers, SQLite store, TTL resolver (audited), `${secret:}` config expander, `AddZbSecrets`. | +| `ZB.MOM.WW.Secrets.Ui` | Blazor RCL on `ZB.MOM.WW.Theme`: list / add / rotate / delete + policy-gated, audited reveal. | + +A `secret` CLI (`set` / `get` / `list` / `rm` / `rotate`) ships in the repo (not packed). + +## How it protects secrets + +Each value is encrypted with a fresh **data key (DEK)** using AES-256-GCM; the DEK is then +**wrapped** by a **master KEK** that never touches the database. The AAD binds the ciphertext +to the secret's name and the wrapped DEK to the KEK id, so rows can't be swapped and a +tampered/mis-keyed row **fails closed** (`SecretDecryptionException`) rather than returning a +wrong value. The master KEK is resolved through a pluggable `IMasterKeyProvider` +(environment variable, key file, or Windows DPAPI). + +## Wiring it up + +```csharp +builder.Services.AddZbSecrets(builder.Configuration, "Secrets"); + +// After config load, BEFORE options validation — expand ${secret:...} references: +var expander = new SecretReferenceExpander(app.Services.GetRequiredService()); +await expander.ExpandConfigurationAsync((IConfigurationRoot)builder.Configuration, ct); +``` + +```jsonc +// appsettings.json +"Secrets": { + "SqlitePath": "secrets.db", + "MasterKey": { "Source": "Environment", "EnvVarName": "ZB_SECRETS_MASTER_KEY" }, + "RunMigrationsOnStartup": true, + "ResolveCacheTtl": "00:00:30" +}, +// Keep plaintext out of config — reference a stored secret instead: +"ConnectionStrings": { + "Historian": "Server=...;User Id=sa;Password=${secret:sql/historian-password}" +} +``` + +The master key is 32 bytes, provided base64 in `ZB_SECRETS_MASTER_KEY` (Environment source). +A missing/invalid key fails closed at startup (`MasterKeyUnavailableException`). + +## Runtime + human access + +- **App code:** inject `ISecretResolver` and call `GetAsync(name, ct)`. Every resolve is + audited via `ZB.MOM.WW.Audit` (name + outcome only — the value is never logged). +- **Operators:** mount the `ZB.MOM.WW.Secrets.Ui` `/admin/secrets` page. The list shows + **metadata only**; revealing a value requires the `secrets:reveal` authorization policy and + is audited. Add/rotate/delete require `secrets:manage`. + +## Clustered deployments + +Secrets storage is local SQLite by default. The schema already carries the +`revision` / `updated_utc` / tombstone columns and an `ISecretReplicator` seam for +cluster replication, but the Akka replicator (`ZB.MOM.WW.Secrets.Akka`) is a deferred +follow-on. **When replicating across a node pair, every node must resolve the _same_ master +KEK** (e.g. the same mounted key file) — a row wrapped by an unknown KEK fails closed.