OPC UA secure client cannot connect: application certificate is never configured #121

Open
opened 2026-11-10 12:11:04 -05:00 by dohertj2 · 2 comments
Owner

Found during lmxopcua#782 OPC UA SDK 2.0 interoperability validation. This is reproduced against BOTH the original OtOpcUa SDK 1.5.378.182 server artifact and the candidate SDK 2.0.1 server; it is a pre-existing consumer limitation, not a server-upgrade regression.

ScadaBridge source 0e89966da6, actual OpcUaDataConnection -> RealOpcUaClient, pinned OPC Foundation SDK 1.5.378.106. A separate process used task-owned issuer/trusted/rejected stores and explicit server DER trust, AutoAcceptUntrustedCerts=false. The None/API-key consumed subset passed 9 cases against each server (raw/UNS scalar and Int32[] round trips, durable browsing, monitoring quality/time, alarm filtering/refresh, reconnect). The same actual adapter fails SignAndEncrypt against both server versions before activation:

ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found. (BadConfigurationError, 0x80890000).

RealOpcUaClient.ConnectAsync constructs ApplicationCertificate = new CertificateIdentifier() without an own-store/subject/application-certificate provisioning path. OpcUaGlobalOptions exposes only issuer/trusted/rejected stores. The corresponding endpoint probe builds the same empty application identifier. Do not solve this by enabling autoaccept or weakening the server profile.

Implement configurable application identity/certificate ownership (own store, subject and URI), provision/load the appropriate key/certificate through the pinned SDK, retain strict server and mutual-client trust, and dispose owned resources. Add actual trusted Sign and SignAndEncrypt allow/deny/reconnect tests through both ConnectAsync and the endpoint probe. Existing None behavior must remain supported. Verify existing persisted PKI is reused rather than silently rotated.

Reproduction harness will be checked into lmxopcua under scripts/validation/scadabridge-sdk-compatibility; local original evidence: artifacts/opcua2/1840b5cc/scadabridge-matrix1/{baseline,candidate}-SignAndEncrypt/consumer/result.json. These are local artifacts, not a deployment. This blocks claiming secure ScadaBridge interoperability; lmxopcua#782 remains open for remaining gates. No wonder-box remote execution or deployment was performed.

Found during lmxopcua#782 OPC UA SDK 2.0 interoperability validation. This is reproduced against BOTH the original OtOpcUa SDK 1.5.378.182 server artifact and the candidate SDK 2.0.1 server; it is a pre-existing consumer limitation, not a server-upgrade regression. ScadaBridge source 0e89966da670e289bd875166295bb0e85d8cf8b2, actual OpcUaDataConnection -> RealOpcUaClient, pinned OPC Foundation SDK 1.5.378.106. A separate process used task-owned issuer/trusted/rejected stores and explicit server DER trust, AutoAcceptUntrustedCerts=false. The None/API-key consumed subset passed 9 cases against each server (raw/UNS scalar and Int32[] round trips, durable browsing, monitoring quality/time, alarm filtering/refresh, reconnect). The same actual adapter fails SignAndEncrypt against both server versions before activation: `ApplicationCertificate for the security profile http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 cannot be found.` (`BadConfigurationError`, 0x80890000). RealOpcUaClient.ConnectAsync constructs `ApplicationCertificate = new CertificateIdentifier()` without an own-store/subject/application-certificate provisioning path. OpcUaGlobalOptions exposes only issuer/trusted/rejected stores. The corresponding endpoint probe builds the same empty application identifier. Do not solve this by enabling autoaccept or weakening the server profile. Implement configurable application identity/certificate ownership (own store, subject and URI), provision/load the appropriate key/certificate through the pinned SDK, retain strict server and mutual-client trust, and dispose owned resources. Add actual trusted Sign and SignAndEncrypt allow/deny/reconnect tests through both ConnectAsync and the endpoint probe. Existing None behavior must remain supported. Verify existing persisted PKI is reused rather than silently rotated. Reproduction harness will be checked into lmxopcua under scripts/validation/scadabridge-sdk-compatibility; local original evidence: artifacts/opcua2/1840b5cc/scadabridge-matrix1/{baseline,candidate}-SignAndEncrypt/consumer/result.json. These are local artifacts, not a deployment. This blocks claiming secure ScadaBridge interoperability; lmxopcua#782 remains open for remaining gates. No wonder-box remote execution or deployment was performed.
dohertj2 added the type:bugpriority:p1cross-repo labels 2026-11-10 12:11:04 -05:00
Author
Owner

Fixed in 167d04cc (local main, not pushed, not deployed).

Root cause — as reported: both RealOpcUaClient.ConnectAsync and VerifyEndpointAsync built an empty CertificateIdentifier. A second defect sat on the same path: CertStoreActor wrote operator-trusted server certs to the trusted-store root, but the SDK reads {store}/certs/. So a Central UI Trust never affected validation, and the certs the SDK rejected never showed up in the list.

Change

  • OpcUaApplicationIdentity builds the client config for both paths. Sign/SignAndEncrypt load the app cert from a configurable own store, found by subject, carrying the ApplicationUri. A self-signed RSA-2048 cert is created only when none exists, and creation is serialised per store and logged at Warning with the thumbprint. A persisted cert is reused and never rotated: if it no longer validates (e.g. URI mismatch), the connect fails. None never touches the own store. AutoAccept is unchanged and the probe still forces it off.
  • New ScadaBridge:OpcUa keys: ApplicationUri, ApplicationCertificateSubject, OwnCertificateStorePath, AutoCreateApplicationCertificate, ApplicationCertificateLifetimeMonths.
  • The private-key cert is disposed on disconnect, on a failed connect and when a probe ends. Sessions are now disposed after close.
  • CertStoreActor now uses certs/ and moves legacy root-level DERs into it on start.
  • Rig site nodes (docker/, docker-env2/) keep their PKI on /app/data/pki/* with a pinned URI.

Tests: an in-process strict-trust StandardServer drives the real adapter through ConnectAsync and the endpoint probe, for Sign and SignAndEncrypt:

  • allowed: read, reconnect and restart all on one identity
  • denied with BadCertificateUntrusted when the client doesn't trust the server
  • denied with BadSecurityChecksFailed when the server doesn't trust the client
  • None leaves the own store untouched
  • 8 concurrent provisions create exactly one cert
  • a URI mismatch fails without rotating the cert
  • with auto-create off, a missing cert fails the connect

The same tests run against the pre-fix client fail with this issue's exact error. DCL 438, SiteRuntime 1000 and Host 597 tests all pass.

Remaining before close: push, rig redeploy, and a re-run of the lmxopcua#782 scadabridge-sdk-compatibility SignAndEncrypt matrix against this build. Operator note: the client cert DER to hand to servers is in {OwnCertificateStorePath}/certs/.

Fixed in `167d04cc` (local `main`, **not pushed, not deployed**). **Root cause** — as reported: both `RealOpcUaClient.ConnectAsync` and `VerifyEndpointAsync` built an empty `CertificateIdentifier`. A second defect sat on the same path: `CertStoreActor` wrote operator-trusted server certs to the trusted-store **root**, but the SDK reads `{store}/certs/`. So a Central UI *Trust* never affected validation, and the certs the SDK rejected never showed up in the list. **Change** - `OpcUaApplicationIdentity` builds the client config for both paths. Sign/SignAndEncrypt load the app cert from a configurable own store, found by subject, carrying the ApplicationUri. A self-signed RSA-2048 cert is created only when none exists, and creation is serialised per store and logged at Warning with the thumbprint. A persisted cert is **reused and never rotated**: if it no longer validates (e.g. URI mismatch), the connect fails. `None` never touches the own store. AutoAccept is unchanged and the probe still forces it off. - New `ScadaBridge:OpcUa` keys: `ApplicationUri`, `ApplicationCertificateSubject`, `OwnCertificateStorePath`, `AutoCreateApplicationCertificate`, `ApplicationCertificateLifetimeMonths`. - The private-key cert is disposed on disconnect, on a failed connect and when a probe ends. Sessions are now disposed after close. - `CertStoreActor` now uses `certs/` and moves legacy root-level DERs into it on start. - Rig site nodes (`docker/`, `docker-env2/`) keep their PKI on `/app/data/pki/*` with a pinned URI. **Tests**: an in-process strict-trust `StandardServer` drives the real adapter through `ConnectAsync` **and** the endpoint probe, for Sign and SignAndEncrypt: - allowed: read, reconnect and restart all on one identity - denied with `BadCertificateUntrusted` when the client doesn't trust the server - denied with `BadSecurityChecksFailed` when the server doesn't trust the client - `None` leaves the own store untouched - 8 concurrent provisions create exactly one cert - a URI mismatch fails without rotating the cert - with auto-create off, a missing cert fails the connect The same tests run against the pre-fix client fail with this issue's exact error. DCL 438, SiteRuntime 1000 and Host 597 tests all pass. **Remaining before close**: push, rig redeploy, and a re-run of the lmxopcua#782 `scadabridge-sdk-compatibility` SignAndEncrypt matrix against this build. Operator note: the client cert DER to hand to servers is in `{OwnCertificateStorePath}/certs/`.
Author
Owner

Pushed (ff835447, rebased onto current main, plus follow-up 36c64a6b) and deployed to the docker rig. All 8 nodes are healthy and the pending central migrations ran: the AuditLog clustered-key flip took ~17 min on the rig's first boot of this build.

Rig live gate: site → opc-plc (scadabridge-opcua:50000), SignAndEncrypt, AutoAccept off

  1. The probe got past the client certificate: UntrustedCertificate, with opc-plc's cert 0EF03980… captured. Before the fix this step failed with BadConfigurationError. site-a's active node created its identity in /app/data/pki/own.
  2. data-connection certs trust wrote the DER to /app/data/pki/trusted/certs/ on both site-a nodes, which is where the SDK reads it.
  3. Re-probes: success: true, twice.
  4. After a full container recreate (deploy.sh), site-a still had the same single identity CCD0781F… and the same trust, and the probe still succeeded, so nothing rotated.

Found on the rig, fixed in 36c64a6b: the site's DataConnectionManagerActor passed the probe a NullLogger. A first probe is usually what creates a node's identity, so the creation Warning was being discarded. It now reaches the node log, e.g. on site-b: OPC UA application certificate CREATED: … thumbprint 7C465F1E…, ApplicationUri urn:scadabridge-site-b-node-a:ScadaBridge-DCL, store /app/data/pki/own …. A test pins that it is logged once, not on reuse.

Note: the SDK appends O=OPC Foundation to an auto-created certificate's subject. This is cosmetic, and lookup still works because the configured subject's CN matches.

Still open: re-run the lmxopcua#782 scadabridge-sdk-compatibility SignAndEncrypt matrix against this build, then close. QA (vq03/vq04) and DEV (vd03) are not deployed.

**Pushed** (`ff835447`, rebased onto current main, plus follow-up `36c64a6b`) and **deployed to the docker rig**. All 8 nodes are healthy and the pending central migrations ran: the AuditLog clustered-key flip took ~17 min on the rig's first boot of this build. **Rig live gate**: site → `opc-plc` (`scadabridge-opcua:50000`), SignAndEncrypt, AutoAccept **off** 1. The probe got past the client certificate: `UntrustedCertificate`, with opc-plc's cert `0EF03980…` captured. Before the fix this step failed with `BadConfigurationError`. site-a's active node created its identity in `/app/data/pki/own`. 2. `data-connection certs trust` wrote the DER to `/app/data/pki/trusted/certs/` on **both** site-a nodes, which is where the SDK reads it. 3. Re-probes: `success: true`, twice. 4. After a full container recreate (`deploy.sh`), site-a still had the same single identity `CCD0781F…` and the same trust, and the probe still succeeded, so nothing rotated. **Found on the rig, fixed in `36c64a6b`**: the site's `DataConnectionManagerActor` passed the probe a `NullLogger`. A first probe is usually what creates a node's identity, so the creation Warning was being discarded. It now reaches the node log, e.g. on site-b: `OPC UA application certificate CREATED: … thumbprint 7C465F1E…, ApplicationUri urn:scadabridge-site-b-node-a:ScadaBridge-DCL, store /app/data/pki/own …`. A test pins that it is logged once, not on reuse. Note: the SDK appends `O=OPC Foundation` to an auto-created certificate's subject. This is cosmetic, and lookup still works because the configured subject's CN matches. **Still open:** re-run the lmxopcua#782 `scadabridge-sdk-compatibility` SignAndEncrypt matrix against this build, then close. QA (vq03/vq04) and DEV (vd03) are not deployed.
Sign in to join this conversation.