Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Tests/Adapters/RealOpcUaSecureConnectTests.cs
T
Joseph Doherty a2e9d71824 feat(dcl): move the OPC UA client to OPC Foundation SDK 2.0.1 (#125)
OPCFoundation.NetStandard.Opc.Ua.Client and the test-only .Server move
1.5.378.182 -> 2.0.1, the version OtOpcUa master runs (lmxopcua#782; #799
and #801 closed). Transitive Core/Core.Types/Types/Configuration/
Security.Certificates resolve to 2.0.1.

API breaks fixed in the DCL adapter:
- Built-in types are immutable structs (Variant, DataValue, NodeId,
  LocalizedText, StatusCode, ArrayOf<T>). New OpcUaValueBoundary restores
  the SDK 1.5 CLR shapes for every read, subscription and alarm-event
  value (byte[], UTC DateTime with no-timestamp = MinValue, Guid,
  detached arrays) and builds write variants with SDK 1.5's
  new Variant(object) semantics (byte[] = ByteString scalar).
- StatusCodes members are no longer constants: switches/patterns over
  them (verify-outcome mapping, connection-level classification,
  BadResourceUnavailable) match by value.
- CertificateIdentifier no longer caches a certificate and the
  CertificateValidator/CertificateValidation event is gone: the
  configuration's CertificateManager owns trust lists and the loaded
  application certificate and is disposed with the session; auto-accept
  and the verify probe's capture-and-reject use AcceptError.
- new UserIdentity(X509Certificate2) is gone: X509 user identities use
  X509IdentityTokenHandler with an on-demand PFX provider (still fails
  fast on a missing file/key).
- DefaultSessionFactory takes the telemetry context; collections are
  ArrayOf<T>; EventFilter select clauses and ContentFilter operands are
  built immutably.

UserName identity (lmxopcua#880): SDK 2's default handler sends a
password over 64 bytes - every 65-character OtOpcUa API key - as an
RsaEncryptedSecret that SDK 1.5 servers reject with BadIdentityTokenInvalid
and SDK 2 servers accept only within a 5-minute clock window (#873). The
DCL now always uses LegacyRsaUserNameIdentityTokenHandler (ported from
OtOpcUa be28aa44), i.e. exactly what it sent on SDK 1.5, so it logs in to
both server generations during a mixed rollout.

Tests: the in-process secured test server is ported to SDK 2 and can
admit an API key; new tests cover the handler (mutation-checked: 5 of 7
fail with the SDK default handler), the value boundary, and a 65-char key
end to end over None/Sign/SignAndEncrypt with a wrong key reaching the
key check (BadIdentityTokenRejected).

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-12 22:20:53 -05:00

316 lines
15 KiB
C#

using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
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>
/// The probe is usually what first creates a node's identity, so creation must reach the
/// supplied logger as a Warning carrying the thumbprint servers must trust — once, not on reuse.
/// </summary>
[Fact]
public async Task Verify_FirstSecuredProbe_LogsCertificateCreationOnce()
{
await using var server = await SecureOpcUaTestServer.StartAsync();
var log = new CapturingLogger();
for (var i = 0; i < 2; i++)
await RealOpcUaClient.VerifyEndpointAsync(
ProbeConfig(server.Url, OpcUaSecurityMode.SignAndEncrypt), ClientOptions(), log,
TimeSpan.FromSeconds(30), CancellationToken.None);
var der = Assert.Single(Directory.GetFiles(OwnCertsDir, "*.der"));
using var cert = X509CertificateLoader.LoadCertificate(await File.ReadAllBytesAsync(der));
var created = Assert.Single(log.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("CREATED"));
Assert.Contains(cert.Thumbprint, created.Message);
}
private sealed class CapturingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
}
/// <summary>
/// ScadaBridge#125 / lmxopcua#880: a production-length (65-character) API key logs in over every
/// security mode, end to end through the real adapter, and a wrong key of the same length reaches
/// the server's key check (<c>BadIdentityTokenRejected</c>) rather than failing to decrypt
/// (<c>BadIdentityTokenInvalid</c>). The server here is SDK 2, which reads both password formats;
/// the SDK 1.5 direction is covered by the wire-compat harness and the handler's own tests.
/// </summary>
[Theory]
[InlineData("None")]
[InlineData("Sign")]
[InlineData("SignAndEncrypt")]
public async Task Connect_ProductionLengthApiKey_IsAdmitted_AndAWrongKeyReachesTheKeyCheck(string mode)
{
var key = OpcUaUserIdentitiesTests.ProductionLengthKey();
using var clientCert = await ProvisionClientAsync();
await using var server = await SecureOpcUaTestServer.StartWithApiKeyAsync(key, clientCert);
ClientTrusts(server.Certificate);
OpcUaConnectionOptions WithKey(string password) => Secured(mode) with
{
UserIdentity = new OpcUaUserIdentityOptions("UsernamePassword", "apikey", password, null!, null!),
};
await using (var client = new RealOpcUaClient(ClientOptions()))
{
await client.ConnectAsync(server.Url, WithKey(key));
Assert.Equal(0u, (await client.ReadValueAsync(ServerStateNode)).StatusCode);
}
var wrong = key[..^1] + (key[^1] == 'A' ? 'B' : 'A');
await using var refused = new RealOpcUaClient(ClientOptions());
var ex = await Assert.ThrowsAsync<Opc.Ua.ServiceResultException>(
() => refused.ConnectAsync(server.Url, WithKey(wrong)));
Assert.Equal(Opc.Ua.StatusCodes.BadIdentityTokenRejected, ex.StatusCode);
}
/// <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"));
using var wrapped = new Opc.Ua.Security.Certificates.Certificate(again.RawData);
Assert.True(Opc.Ua.X509Utils.CompareApplicationUriWithCertificate(wrapped, "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);
}
}