fix(dcl): OPC UA secure client presents a real application certificate (#121)

Sign/SignAndEncrypt sessions failed before activation with "ApplicationCertificate
for the security profile ... cannot be found" (BadConfigurationError): both
RealOpcUaClient.ConnectAsync and the verify probe built an empty CertificateIdentifier.

- New OpcUaApplicationIdentity builds the client ApplicationConfiguration for both
  paths. Secured modes load the application certificate from a configurable own
  store (subject + ApplicationUri), auto-creating a self-signed RSA-2048 cert only
  when none exists. A persisted cert is reused across reconnect/restart and never
  silently rotated: one that no longer validates (e.g. ApplicationUri mismatch)
  fails closed. First-time creation is serialised per own store and logged at
  Warning with the thumbprint. None mode is unchanged and never touches the store.
- New OpcUaGlobalOptions: ApplicationUri, ApplicationCertificateSubject,
  OwnCertificateStorePath, AutoCreateApplicationCertificate,
  ApplicationCertificateLifetimeMonths (validated).
- Owned resources: the private-key cert is disposed on disconnect, failed connect
  and at probe end; sessions are now disposed after close, and a reconnect on the
  same instance closes the previous session first.
- CertStoreActor wrote trusted server certs to the store ROOT, which the SDK never
  reads (it uses {store}/certs), so an operator Trust never affected validation and
  SDK-rejected certs never listed. It now uses certs/ and migrates legacy root files
  on start.
- Rig: all site nodes (docker/, docker-env2/) put the four PKI stores on the
  persistent /app/data volume with a pinned ApplicationUri.
- Tests: in-process strict-trust StandardServer (test-only Opc.Ua.Server 1.5.378.106)
  exercising the real adapter through ConnectAsync and VerifyEndpointAsync for Sign
  and SignAndEncrypt: allow + read + reconnect + restart on one identity, deny when
  the client doesn't trust the server (BadCertificateUntrusted), deny when the server
  doesn't trust the client (BadSecurityChecksFailed), None untouched, concurrent
  provisioning creates one cert, URI mismatch fails without rotating, auto-create off.
  Against the pre-fix client these tests fail with the exact #121 error.
This commit is contained in:
Joseph Doherty
2026-11-10 13:34:55 -05:00
parent c810bd0f7c
commit ff8354478c
22 changed files with 948 additions and 74 deletions
+7 -2
View File
@@ -259,11 +259,16 @@ All settings under `DataConnectionLayer` in `appsettings.json` bind to `DataConn
| Key | Default | Description |
|-----|---------|-------------|
| `ApplicationName` | `ScadaBridge-DCL` | Application name used in the OPC UA certificate and session negotiation. |
| `ApplicationUri` | `""` → `urn:{host}:{ApplicationName}` | ApplicationUri presented in the session and embedded in the application certificate. |
| `ApplicationCertificateSubject` | `""` → `CN={ApplicationName}` | Subject the application certificate is located by. |
| `OwnCertificateStorePath` | `""` | Own store holding the application certificate + private key (Sign / SignAndEncrypt only). |
| `AutoCreateApplicationCertificate` | `true` | Create a self-signed certificate when none exists; `false` fails a secured connect instead. |
| `ApplicationCertificateLifetimeMonths` | `24` | Lifetime of an auto-created certificate (1–600). |
| `TrustedIssuerStorePath` | `""` | File path to the trusted issuer certificate store. |
| `TrustedPeerStorePath` | `""` | File path to the trusted peer certificate store. |
| `TrustedPeerStorePath` | `""` | File path to the trusted peer certificate store (certificates live in its `certs/` sub-directory). |
| `RejectedCertificateStorePath` | `""` | File path to the rejected certificate store. |
Empty store paths fall back to a temp-path default so dev runs work without explicit configuration.
Empty store paths fall back to a temp-path default so dev runs work without explicit configuration — production nodes must use persistent paths, or the application certificate changes whenever temp is cleared. An existing application certificate is reused, never silently rotated; one that no longer matches the configured ApplicationUri fails the connect. See `docs/requirements/Component-DataConnectionLayer.md` → *OPC UA application identity* (Gitea #121).
### Per-connection settings (OPC UA)
@@ -184,6 +184,28 @@ All settings are parsed from the data connection's configuration JSON dictionari
| `SecurityMode` | string | `None` | Preferred endpoint security: `None`, `Sign`, or `SignAndEncrypt` |
| `AutoAcceptUntrustedCerts` | bool | `true` | Accept untrusted server certificates |
### OPC UA application identity (deployment-wide, `ScadaBridge:OpcUa`)
A `Sign` / `SignAndEncrypt` session presents ScadaBridge's own **application (client) certificate** to the server; `None` presents none. Before Gitea #121 the client built an empty certificate identifier, so every secured session failed before activation with `ApplicationCertificate for the security profile … cannot be found` (`BadConfigurationError`) — secured OPC UA was never actually usable.
| Key | Default | Description |
|-----|---------|-------------|
| `ApplicationName` | `ScadaBridge-DCL` | Application name (session + certificate). |
| `ApplicationUri` | `urn:{host}:{ApplicationName}` | ApplicationUri presented in the session and carried as the certificate's SAN URI. Pin it on hosts whose name can change. |
| `ApplicationCertificateSubject` | `CN={ApplicationName}` | Subject the certificate is located by in the own store. |
| `OwnCertificateStorePath` | temp default | Own store (certificate + private key). |
| `AutoCreateApplicationCertificate` | `true` | Generate a self-signed RSA-2048 certificate the first time a secured session needs one and none exists. `false` = a missing certificate fails the connect (bring-your-own PKI). |
| `ApplicationCertificateLifetimeMonths` | `24` | Lifetime of an auto-created certificate (1–600). |
| `TrustedPeerStorePath` / `TrustedIssuerStorePath` / `RejectedCertificateStorePath` | temp default | Server trust stores. The SDK reads/writes certificates in each store's **`certs/`** sub-directory. |
Rules (`Adapters/OpcUaApplicationIdentity.cs`, shared by `ConnectAsync` and the verify probe):
- **Reuse, never silent rotation.** A certificate already in the own store is loaded and reused across reconnects and restarts. One that no longer validates (ApplicationUri mismatch, undersized key, unreadable private key) **fails the connect** with the SDK's "update or delete the certificate" error — it is never regenerated over an identity servers already trust. Creation happens only when the store holds no certificate for the subject, and logs a **Warning** with the thumbprint (every secured server must then trust it). Concurrent first connects are serialised per own store, so they create exactly one certificate.
- **Strict trust both ways.** Server trust is unchanged (`AutoAcceptUntrustedCerts`, off by default in the adapter; the probe forces it off). The client's own certificate is NOT copied into the trusted-peer store (`AddAppCertToTrustedStore = false`) — that store stays the operator-managed server list. The server must trust the client certificate (copy the DER from `{OwnCertificateStorePath}/certs/` into the server's trust list); until it does, the session fails with `BadSecurityChecksFailed`.
- **`None` is untouched** — it neither reads nor creates the own store.
- **Owned resources** — the private-key-bearing certificate is released (disposed) when the session is closed, on a failed connect, and at the end of every probe; a reconnect on the same client instance closes and disposes the previous session first.
- **Persist the stores.** Every store path must be on a persistent volume on a real node — on the temp default a cleared temp directory means a new identity that every server must re-trust. The docker rig points all four at `/app/data/pki/*`.
### MxGateway Settings
| Key | Type | Default | Description |
@@ -285,6 +307,7 @@ IAddressSpaceSearchable
Before saving or deploying an OPC UA data connection, the Central UI can ask the target site to **probe** the configured endpoint — this exercises connectivity (and TLS trust) against the live config, including edited-but-unsaved values.
- `DataConnectionManagerActor` handles `VerifyEndpointCommand` (`SiteId`, protocol, config JSON). The site spins up a **temporary** `RealOpcUaClient` from the submitted config, attempts discovery + a session with a short timeout (a few seconds — the budget bounds discovery too, not only the session create), then disconnects and disposes the client. The reply is a `VerifyEndpointResult` (`Success`, a typed `FailureKind`, an error message, and an optional captured server cert).
- A `Sign` / `SignAndEncrypt` probe presents the **same application certificate** a real connect would (see *OPC UA application identity* above), so a probe success proves the real connect's mutual trust too.
- The probe forces **`AutoAcceptUntrustedCerts = false`** and hooks the certificate-validation callback so it can **capture** an untrusted server certificate (Subject / Issuer / Thumbprint / NotBefore / NotAfter / DER) for the UI to display. Critically, the probe **never trusts** the cert — the validation callback always rejects (`Accept = false`); trusting is a separate, explicit, Admin-gated action that writes the cert into the site PKI store (see the cert-trust path in Component-SiteRuntime.md). The probe is read-only with respect to the trust boundary.
## Native Alarm Mirroring
+1 -1
View File
@@ -193,7 +193,7 @@ flowchart TD
### Server Certificate Trust (Per-Node Broadcast)
- The OPC UA trusted-peer certificate store is **node-local** — it lives in each node's PKI directory, so trusting a cert on only the active node would silently fail certificate validation after failover. To keep both nodes consistent, certificate trust is handled by a per-node actor and a broadcast.
- **`CertStoreActor`** is a per-node, **non-singleton** actor — it runs on **every** site node (unlike the Deployment Manager singleton). It owns its node's OPC UA PKI trusted-peer / rejected stores and handles three commands:
- **`TrustServerCertCommand`** — write a base64/DER server certificate into the node's trusted-peer store (path-traversal-guarded thumbprints; the cert is decoded and persisted as a `.der`).
- **`TrustServerCertCommand`** — write a base64/DER server certificate into the node's trusted-peer store (path-traversal-guarded thumbprints; the cert is decoded and persisted as a `.der` in the store's **`certs/`** sub-directory — where the OPC UA SDK's directory store reads it. Before Gitea #121 the actor wrote to the store root, which the SDK never reads, so an operator Trust had no effect on validation; on start the actor now moves any such legacy root-level `.der`/`.crt` into `certs/`, and the rejected listing reads `rejected/certs/` where the SDK quarantines refused server certificates).
- **`RemoveServerCertCommand`** — delete a certificate (by thumbprint) from the node's stores.
- **`ListServerCertsCommand`** — enumerate the node's trusted-peer + rejected store contents (Subject / Issuer / Thumbprint / validity / status).
- The Deployment Manager singleton receives the central cert-trust verbs (relayed via the Communication Layer) and **broadcasts** `TrustServerCertCommand` / `RemoveServerCertCommand` to the `CertStoreActor` on **both** site nodes (via `ActorSelection`), so node-a and node-b PKI stores stay in sync. List reads from the local node. Trust is **site-local** — there is no central persistence of trusted certs (logged as a follow-up). The captured-but-untrusted server cert that seeds this flow comes from the DCL verify-endpoint probe (see Component-DataConnectionLayer.md), which never trusts on its own; the Central UI surfaces Trust / Remove (Admin-gated — see Component-CentralUI.md).