a2e9d71824
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
316 lines
15 KiB
C#
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);
|
|
}
|
|
}
|