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; /// /// Gitea #121: the real against a real, strictly-trusting /// in-process server. Before the fix every Sign / SignAndEncrypt session failed before /// activation with ApplicationCertificate … cannot be found (BadConfigurationError) /// because the client never configured an application certificate. These tests run the actual /// adapter through both and /// , with AutoAccept OFF on both sides. /// 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")); /// 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"); /// Trusts the server on the client side exactly as the site CertStoreActor does. 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 ProvisionClientAsync(OpcUaGlobalOptions? options = null) => await OpcUaApplicationIdentity.EnsureApplicationCertificateAsync(options ?? ClientOptions(), NullLogger.Instance); /// Mutual trust: connects, reads, reconnects and restarts on ONE persisted identity. [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); } /// Strict server trust: a server the client has not trusted is refused, and quarantined in rejected/. [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(() => 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); } /// Strict mutual trust: a server that has not trusted the client refuses the channel. [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(() => 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); } /// The verify probe presents the same identity: success under mutual trust. [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")); } /// The probe captures — and never trusts — an untrusted server certificate. [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"); } /// The probe fails when the server has not trusted the client. [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 } /// /// 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. /// [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 state) where TState : notnull => null; public bool IsEnabled(LogLevel logLevel) => true; public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { lock (Entries) Entries.Add((logLevel, formatter(state, exception))); } } /// None is unchanged: it connects without reading or creating the own store. [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"))); } /// A persisted identity is reused, never rotated — including when concurrent connects race on an empty store. [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(); } } /// A persisted certificate that no longer matches the ApplicationUri fails closed instead of being replaced. [Fact] public async Task Provisioning_ApplicationUriMismatch_FailsWithoutRotating() { using var original = await ProvisionClientAsync(); await Assert.ThrowsAsync( () => 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); } /// With auto-create off, a missing certificate fails the secured connect rather than inventing one. [Fact] public async Task Provisioning_AutoCreateDisabled_MissingCertificateFails() { await Assert.ThrowsAsync(() => ProvisionClientAsync(ClientOptions(autoCreate: false))); Assert.False(Directory.Exists(OwnCertsDir) && Directory.GetFiles(OwnCertsDir).Length > 0); } }