36c64a6bb8
The site's DataConnectionManagerActor handed VerifyEndpointAsync a NullLogger. A secured probe is usually what first creates a node's application certificate, so the creation Warning carrying the thumbprint every server must trust was silently discarded (found on the rig live gate). The actor takes an optional probe logger; the Host supplies one. Test pins creation logged once (Warning, thumbprint), not on reuse.
279 lines
13 KiB
C#
279 lines
13 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>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);
|
|
}
|
|
}
|