Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs
T
Joseph Doherty ff8354478c 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.
2026-11-10 13:56:47 -05:00

242 lines
12 KiB
C#

using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.DataConnections;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests.Adapters;
/// <summary>
/// Gitea #121: the real <see cref="RealOpcUaClient"/> against a real, strictly-trusting
/// in-process server. Before the fix every Sign / SignAndEncrypt session failed before
/// activation with <c>ApplicationCertificate … cannot be found</c> (BadConfigurationError)
/// because the client never configured an application certificate. These tests run the actual
/// adapter through both <see cref="RealOpcUaClient.ConnectAsync"/> and
/// <see cref="RealOpcUaClient.VerifyEndpointAsync"/>, with AutoAccept OFF on both sides.
/// </summary>
public sealed class RealOpcUaSecureConnectTests : IDisposable
{
// Server_ServerStatus_State — present on every server, readable anonymously.
private const string ServerStateNode = "i=2259";
private readonly string _clientPki =
Path.Combine(Path.GetTempPath(), "sb121-client", Guid.NewGuid().ToString("N"));
/// <inheritdoc />
public void Dispose()
{
try { Directory.Delete(_clientPki, recursive: true); } catch { /* best-effort */ }
}
private OpcUaGlobalOptions ClientOptions(string? applicationUri = null, bool autoCreate = true) => new()
{
ApplicationName = "SB121Client",
ApplicationUri = applicationUri ?? "urn:localhost:SB121Client",
OwnCertificateStorePath = Path.Combine(_clientPki, "own"),
TrustedPeerStorePath = Path.Combine(_clientPki, "trusted"),
TrustedIssuerStorePath = Path.Combine(_clientPki, "issuers"),
RejectedCertificateStorePath = Path.Combine(_clientPki, "rejected"),
AutoCreateApplicationCertificate = autoCreate,
};
private string OwnCertsDir => Path.Combine(_clientPki, "own", "certs");
/// <summary>Trusts the server on the client side exactly as the site CertStoreActor does.</summary>
private void ClientTrusts(X509Certificate2 server)
{
var dir = Path.Combine(_clientPki, "trusted", "certs");
Directory.CreateDirectory(dir);
File.WriteAllBytes(Path.Combine(dir, $"{server.Thumbprint}.der"), server.RawData);
}
private static OpcUaConnectionOptions Secured(string mode) => new(SecurityMode: mode, AutoAcceptUntrustedCerts: false);
private static OpcUaEndpointConfig ProbeConfig(string url, OpcUaSecurityMode mode) => new()
{
EndpointUrl = url,
SecurityMode = mode,
AutoAcceptUntrustedCerts = false,
};
private async Task<X509Certificate2> ProvisionClientAsync(OpcUaGlobalOptions? options = null) =>
await OpcUaApplicationIdentity.EnsureApplicationCertificateAsync(options ?? ClientOptions(), NullLogger.Instance);
/// <summary>Mutual trust: connects, reads, reconnects and restarts on ONE persisted identity.</summary>
[Theory]
[InlineData("Sign")]
[InlineData("SignAndEncrypt")]
public async Task Connect_WithMutualTrust_ReadsAndReconnectsOnTheSameIdentity(string mode)
{
using var clientCert = await ProvisionClientAsync();
await using var server = await SecureOpcUaTestServer.StartAsync(clientCert);
ClientTrusts(server.Certificate);
var client = new RealOpcUaClient(ClientOptions());
await client.ConnectAsync(server.Url, Secured(mode));
Assert.True(client.IsConnected);
Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode);
// Reconnect on the same instance.
await client.DisconnectAsync();
Assert.False(client.IsConnected);
await client.ConnectAsync(server.Url, Secured(mode));
Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode);
await client.DisposeAsync();
// A fresh instance (process restart) loads the persisted identity.
await using var restarted = new RealOpcUaClient(ClientOptions());
await restarted.ConnectAsync(server.Url, Secured(mode));
Assert.Equal(0u, (await restarted.ReadValueAsync(ServerStateNode)).StatusCode);
var der = Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
using var onDisk = X509CertificateLoader.LoadCertificate(await File.ReadAllBytesAsync(der));
Assert.Equal(clientCert.Thumbprint, onDisk.Thumbprint);
}
/// <summary>Strict server trust: a server the client has not trusted is refused, and quarantined in rejected/.</summary>
[Theory]
[InlineData("Sign")]
[InlineData("SignAndEncrypt")]
public async Task Connect_ServerNotTrustedByClient_IsRefused(string mode)
{
using var clientCert = await ProvisionClientAsync();
await using var server = await SecureOpcUaTestServer.StartAsync(clientCert);
await using var client = new RealOpcUaClient(ClientOptions());
var ex = await Assert.ThrowsAsync<Opc.Ua.ServiceResultException>(() => client.ConnectAsync(server.Url, Secured(mode)));
// A trust refusal — not the pre-#121 BadConfigurationError ("ApplicationCertificate … cannot be found").
Assert.Equal(Opc.Ua.StatusCodes.BadCertificateUntrusted, ex.StatusCode);
Assert.False(client.IsConnected);
}
/// <summary>Strict mutual trust: a server that has not trusted the client refuses the channel.</summary>
[Theory]
[InlineData("Sign")]
[InlineData("SignAndEncrypt")]
public async Task Connect_ClientNotTrustedByServer_IsRefused(string mode)
{
await using var server = await SecureOpcUaTestServer.StartAsync();
ClientTrusts(server.Certificate);
await using var client = new RealOpcUaClient(ClientOptions());
var ex = await Assert.ThrowsAsync<Opc.Ua.ServiceResultException>(() => client.ConnectAsync(server.Url, Secured(mode)));
// The server's refusal of an untrusted client channel — proves a client certificate WAS presented.
Assert.Equal(Opc.Ua.StatusCodes.BadSecurityChecksFailed, ex.StatusCode);
Assert.False(client.IsConnected);
}
/// <summary>The verify probe presents the same identity: success under mutual trust.</summary>
[Theory]
[InlineData(OpcUaSecurityMode.Sign)]
[InlineData(OpcUaSecurityMode.SignAndEncrypt)]
public async Task Verify_WithMutualTrust_Succeeds(OpcUaSecurityMode mode)
{
using var clientCert = await ProvisionClientAsync();
await using var server = await SecureOpcUaTestServer.StartAsync(clientCert);
ClientTrusts(server.Certificate);
var result = await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, mode), ClientOptions(), NullLogger.Instance, TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.True(result.Success, $"{result.FailureKind}: {result.Error}");
Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
}
/// <summary>The probe captures — and never trusts — an untrusted server certificate.</summary>
[Theory]
[InlineData(OpcUaSecurityMode.Sign)]
[InlineData(OpcUaSecurityMode.SignAndEncrypt)]
public async Task Verify_ServerNotTrusted_ReturnsUntrustedCertificate(OpcUaSecurityMode mode)
{
using var clientCert = await ProvisionClientAsync();
await using var server = await SecureOpcUaTestServer.StartAsync(clientCert);
var result = await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, mode), ClientOptions(), NullLogger.Instance, TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.False(result.Success);
Assert.Equal(VerifyFailureKind.UntrustedCertificate, result.FailureKind);
Assert.Equal(server.Certificate.Thumbprint, result.Cert?.Thumbprint);
Assert.False(Directory.Exists(Path.Combine(_clientPki, "trusted", "certs"))
&& Directory.GetFiles(Path.Combine(_clientPki, "trusted", "certs")).Length > 0,
"the probe must never trust the server certificate");
}
/// <summary>The probe fails when the server has not trusted the client.</summary>
[Theory]
[InlineData(OpcUaSecurityMode.Sign)]
[InlineData(OpcUaSecurityMode.SignAndEncrypt)]
public async Task Verify_ClientNotTrustedByServer_Fails(OpcUaSecurityMode mode)
{
await using var server = await SecureOpcUaTestServer.StartAsync();
ClientTrusts(server.Certificate);
var result = await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, mode), ClientOptions(), NullLogger.Instance, TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.False(result.Success);
Assert.NotNull(result.FailureKind);
Assert.Contains("80130000", result.Error); // BadSecurityChecksFailed, not BadConfigurationError
}
/// <summary>None is unchanged: it connects without reading or creating the own store.</summary>
[Fact]
public async Task Connect_None_DoesNotTouchTheOwnStore()
{
await using var server = await SecureOpcUaTestServer.StartAsync();
await using var client = new RealOpcUaClient(ClientOptions());
await client.ConnectAsync(server.Url, new OpcUaConnectionOptions());
Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode);
var probe = await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, OpcUaSecurityMode.None), ClientOptions(), NullLogger.Instance,
TimeSpan.FromSeconds(30), CancellationToken.None);
Assert.True(probe.Success, $"{probe.FailureKind}: {probe.Error}");
Assert.False(Directory.Exists(Path.Combine(_clientPki, "own")));
}
/// <summary>A persisted identity is reused, never rotated — including when concurrent connects race on an empty store.</summary>
[Fact]
public async Task Provisioning_ReusesThePersistedCertificate_AndCreatesOnlyOneUnderConcurrency()
{
var certs = await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => ProvisionClientAsync()));
try
{
Assert.Single(certs.Select(c => c.Thumbprint).Distinct());
using var again = await ProvisionClientAsync();
Assert.Equal(certs[0].Thumbprint, again.Thumbprint);
Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
Assert.True(Opc.Ua.X509Utils.CompareApplicationUriWithCertificate(again, "urn:localhost:SB121Client", out _),
"the certificate carries the configured ApplicationUri");
}
finally
{
foreach (var c in certs) c.Dispose();
}
}
/// <summary>A persisted certificate that no longer matches the ApplicationUri fails closed instead of being replaced.</summary>
[Fact]
public async Task Provisioning_ApplicationUriMismatch_FailsWithoutRotating()
{
using var original = await ProvisionClientAsync();
await Assert.ThrowsAsync<InvalidOperationException>(
() => ProvisionClientAsync(ClientOptions(applicationUri: "urn:elsewhere:SB121Client")));
var der = Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
using var onDisk = X509CertificateLoader.LoadCertificate(await File.ReadAllBytesAsync(der));
Assert.Equal(original.Thumbprint, onDisk.Thumbprint);
}
/// <summary>With auto-create off, a missing certificate fails the secured connect rather than inventing one.</summary>
[Fact]
public async Task Provisioning_AutoCreateDisabled_MissingCertificateFails()
{
await Assert.ThrowsAsync<InvalidOperationException>(() => ProvisionClientAsync(ClientOptions(autoCreate: false)));
Assert.False(Directory.Exists(OwnCertsDir) && Directory.GetFiles(OwnCertsDir).Length > 0);
}
}