diff --git a/docs/security.md b/docs/security.md index 3c925110..a2a234c7 100644 --- a/docs/security.md +++ b/docs/security.md @@ -380,6 +380,51 @@ polling the node. --- +## Config secrets (`${secret:}` delivery) + +OtOpcUa never commits secret values to `appsettings*.json`. Every real secret is +supplied at deploy time — either as a plain environment variable, or as a +`${secret:}` token backed by the shared **`ZB.MOM.WW.Secrets`** encrypted store. + +A pre-host expander (`SecretReferenceExpander.ExpandConfigurationAsync`, wired in +`OtOpcUa.Host/Program.cs`) walks the assembled configuration and rewrites every +`${secret:}` token into its resolved plaintext **before** the host is built — +so every downstream binder/validator (`AddZbSerilog`, `AddOtOpcUaConfigDb`, the first +`ValidateOnStart`) sees resolved values. The store's key-encryption key (KEK) comes from +the `ZB_SECRETS_MASTER_KEY` environment variable (`Secrets:MasterKey:Source=Environment`); +the encrypted SQLite store lives at `Secrets:SqlitePath`. + +The expander is **fail-closed and section-agnostic**: a `${secret:}` token whose +secret is absent throws `SecretNotFoundException` at startup, regardless of which feature +owns the key or whether that feature is enabled. Keys prefixed with `_` (comment keys) are +skipped, so a `${secret:...}` example inside a `_…Comment` value is never resolved. + +The five config secrets and their canonical secret names: + +| Config key | Owner | Secret name | +|---|---|---| +| `Security:Jwt:SigningKey` | `JwtOptions` | `otopcua/jwt/signing-key` | +| `Security:Ldap:ServiceAccountPassword` | `LdapOptions` | `otopcua/ldap/service-account-password` | +| `Security:DeployApiKey` | `DeployApiEndpoints` | `otopcua/deploy/api-key` | +| `ConnectionStrings:ConfigDb` | `AddOtOpcUaConfigDb` | `otopcua/sql/configdb-connstr` | +| `ServerHistorian:ApiKey` | `ServerHistorianOptions` | `otopcua/historian/api-key` | + +**Delivery.** By default these are delivered as plain environment variables (e.g. +`ServerHistorian__ApiKey=histgw_…`), never committed. To deliver one from the encrypted +store instead, seed it once with the `secret` CLI (from `ZB.MOM.WW.Secrets`), then supply +the token in place of the literal — via env var or a deployment appsettings overlay: + +``` +secret set otopcua/historian/api-key # seed the encrypted store first +ServerHistorian__ApiKey='${secret:otopcua/historian/api-key}' +``` + +Only switch a value to a `${secret:}` token **after** the secret is seeded — an unseeded +token fails the boot fail-closed. Do not commit the KEK (`ZB_SECRETS_MASTER_KEY`) or any +secret value. + +--- + ## Troubleshooting ### Certificate trust failure diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json index dbeab448..eea476b0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json @@ -25,7 +25,7 @@ "Enabled": false, "Endpoint": "", "ApiKey": "", - "_ApiKeyComment": "NEVER commit a real key. Supply via the environment variable ServerHistorian__ApiKey.", + "_ApiKeyComment": "NEVER commit a real key. Supply via env var ServerHistorian__ApiKey, either as a literal or as a ${secret:otopcua/historian/api-key} token resolved fail-closed by the pre-host secrets expander.", "UseTls": true, "AllowUntrustedServerCertificate": false, "CaCertificatePath": null, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs index d714ccb9..cc41ce38 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Historian/ServerHistorianOptions.cs @@ -40,8 +40,10 @@ public sealed class ServerHistorianOptions /// /// The peppered-HMAC API key (histgw_<id>_<secret>) the gateway validates /// in the Authorization: Bearer header. Supply via the environment variable - /// ServerHistorian__ApiKey — never commit it to config. Required when - /// is true. + /// ServerHistorian__ApiKey — never commit it to config. The value may be a literal + /// key or a ${secret:otopcua/historian/api-key} token, which the pre-host secrets + /// expander resolves fail-closed (throwing if the secret is absent) before this options + /// class binds. Required when is true. /// public string ApiKey { get; init; } = "";