/* ======================================================================== * Interoperability tests: .NET OPC UA client against open62541 C server. * * Reads OPCUA_INTEROP_SERVER_URL environment variable for the C server * endpoint (e.g. "opc.tcp://localhost:4840"). * * The C server is expected to be an instance of interop_server with: * - User credentials: user1 / password * - Variable: ns=1;s=the.answer (Int32 = 43) * - Method: ns=1;i=62541 (HelloWorld) * ======================================================================*/ using System.Net; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Logging; using NUnit.Framework; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; namespace Opc.Ua.Interop.Tests { internal sealed class InteropTelemetryContext : TelemetryContextBase { public InteropTelemetryContext() : base(Microsoft.Extensions.Logging.LoggerFactory.Create(builder => builder .SetMinimumLevel(LogLevel.Debug))) { } } public abstract class InteropClientTestBase { protected abstract string ServerUrlEnvironmentVariable { get; } protected abstract string DefaultServerUrl { get; } protected virtual bool RequireServerUrl => false; private string _serverUrl = null!; private ApplicationConfiguration _config = null!; private DefaultSessionFactory _sessionFactory = null!; private ITelemetryContext _telemetry = null!; private string _pkiRoot = null!; // ECC sessions that connected. Mirrors INTEROP_REQUIRE_ECC in check_interop_client.c. private static int s_eccTestsPassed; private static readonly object s_eccLock = new object(); [OneTimeSetUp] public async Task OneTimeSetUp() { _serverUrl = Environment.GetEnvironmentVariable( ServerUrlEnvironmentVariable) ?? string.Empty; if (string.IsNullOrWhiteSpace(_serverUrl)) { if (RequireServerUrl) { Assert.Ignore($"Set {ServerUrlEnvironmentVariable} to run this fixture."); } _serverUrl = DefaultServerUrl; } _telemetry = new InteropTelemetryContext(); TestContext.Out.WriteLine($"Interop server URL: {_serverUrl}"); _pkiRoot = Path.Combine(Path.GetTempPath(), "interop_pki_" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(_pkiRoot); var applicationCerts = ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates( "CN=InteropTestClient, O=open62541, DC=localhost", CertificateStoreType.Directory, _pkiRoot); _config = await new ApplicationInstance(_telemetry) { ApplicationName = "InteropTestClient", ApplicationType = ApplicationType.Client } .Build("urn:localhost:open62541:InteropTestClient", "http://open62541.org/UA/InteropTestClient") .AsClient() .AddSecurityConfiguration(applicationCerts, _pkiRoot) .SetAutoAcceptUntrustedCertificates(true) .SetRejectSHA1SignedCertificates(false) .SetMinimumCertificateKeySize(0) .CreateAsync().ConfigureAwait(false); // Generate application certificates if they don't exist var app = new ApplicationInstance(_telemetry) { ApplicationName = "InteropTestClient", ApplicationType = ApplicationType.Client, ApplicationConfiguration = _config }; await app.CheckApplicationInstanceCertificatesAsync(true) .ConfigureAwait(false); // Ensure all ECC certificates exist — the SDK may fail to // auto-generate P384 or brainpool certs on some platforms await EnsureEccCertificatesAsync().ConfigureAwait(false); _sessionFactory = new DefaultSessionFactory(_telemetry); } [OneTimeTearDown] public void OneTimeTearDown() { // Fail when no ECC test actually ran. Mirrors INTEROP_REQUIRE_ECC. var requireEcc = Environment.GetEnvironmentVariable( "INTEROP_REQUIRE_ECC"); bool required = requireEcc != null && requireEcc != "0"; int passed; lock (s_eccLock) { passed = s_eccTestsPassed; } TestContext.Out.WriteLine( $"ECC interop summary: {passed} ECC test(s) passed"); if (required && passed == 0) { Assert.Fail( "INTEROP_REQUIRE_ECC is set but no ECC security " + "policy test established a session against the C server. " + "The open62541 ECC implementation is not being tested. " + "Check that INTEROP_ECC_CERT_DIR is set and the C server " + "logs 'Added ECC policy' on startup."); } } private async Task GetEndpointAsync(bool useSecurity) { var endpoint = await CoreClientUtils.SelectEndpointAsync( _config, _serverUrl, useSecurity: useSecurity, telemetry: _telemetry, ct: CancellationToken.None).ConfigureAwait(false); return new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(_config)); } private Task CreateSessionAsync( ConfiguredEndpoint endpoint, IUserIdentity? identity = null) { return _sessionFactory.CreateAsync( _config, endpoint, false, false, "InteropTest", 30_000u, identity ?? new UserIdentity(), default, CancellationToken.None); } [Test, Order(1)] public async Task ConnectAnonymous() { var endpoint = await GetEndpointAsync(useSecurity: false).ConfigureAwait(false); using var session = await CreateSessionAsync(endpoint).ConfigureAwait(false); Assert.That(session.Connected, Is.True, "Session should be connected"); var serverState = await session.ReadValueAsync( VariableIds.Server_ServerStatus_State, CancellationToken.None).ConfigureAwait(false); Assert.That(serverState, Is.Not.Null); Assert.That(serverState.StatusCode, Is.EqualTo(StatusCodes.Good)); TestContext.Out.WriteLine($"Server state: {serverState.WrappedValue}"); await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); } [Test, Order(2)] public async Task ConnectUsernamePassword() { // T-2: Both outcomes (accept or reject) are valid. // Many secure servers correctly reject username/password over the // None security policy to prevent credential leaking. var endpoint = await GetEndpointAsync(useSecurity: false).ConfigureAwait(false); try { using var session = await CreateSessionAsync(endpoint, new UserIdentity("user1", "password"u8)).ConfigureAwait(false); Assert.That(session.Connected, Is.True, "Session should be connected with username/password"); await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); } catch (Exception) { // T-2 is deliberately ambiguous: both accepting and rejecting // username/password over an unencrypted channel are valid. // Some servers require the password to be encrypted with a // specific security policy (e.g. Aes256_Sha256_RsaPss) even // over a None channel; others simply reject credentials. // Treat every exception as a graceful ignore. Assert.Ignore( "Server correctly rejects or cannot process unencrypted credentials."); } } [Test, Order(3)] public async Task ReadNamespaceArray() { var endpoint = await GetEndpointAsync(useSecurity: false).ConfigureAwait(false); using var session = await CreateSessionAsync(endpoint).ConfigureAwait(false); var namespaceArray = await session.ReadValueAsync( VariableIds.Server_NamespaceArray, CancellationToken.None).ConfigureAwait(false); Assert.That(namespaceArray, Is.Not.Null); Assert.That(namespaceArray.StatusCode, Is.EqualTo(StatusCodes.Good)); object? rawValue = namespaceArray.WrappedValue.Value; string[]? namespaces = rawValue as string[]; if (namespaces == null && rawValue is IEnumerable seq) { namespaces = seq.Select(x => x?.ToString() ?? "").ToArray(); } Assert.That(namespaces, Is.Not.Null, $"Expected string[], got {rawValue?.GetType().FullName ?? "null"}"); Assert.That(namespaces!.Length, Is.GreaterThan(0)); Assert.That(namespaces[0], Is.EqualTo(Namespaces.OpcUa)); TestContext.Out.WriteLine($"Namespaces: {string.Join(", ", namespaces)}"); await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); } [Test, Order(4)] public async Task ReadCustomVariable() { var endpoint = await GetEndpointAsync(useSecurity: false).ConfigureAwait(false); using var session = await CreateSessionAsync(endpoint).ConfigureAwait(false); // interop_server exposes ns=1;s=the.answer as Int32 = 43 var nodeId = new NodeId("the.answer", 1); var value = await session.ReadValueAsync( nodeId, CancellationToken.None).ConfigureAwait(false); Assert.That(value, Is.Not.Null); Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good)); Assert.That(value.WrappedValue.Value, Is.EqualTo(43)); TestContext.Out.WriteLine($"the.answer = {value.WrappedValue.Value}"); await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); } [Test, Order(5)] public async Task CallHelloWorldMethod() { var endpoint = await GetEndpointAsync(useSecurity: false).ConfigureAwait(false); using var session = await CreateSessionAsync(endpoint).ConfigureAwait(false); // interop_server HelloWorld method: ns=1;i=62541 // Parent object: InteropTests (ns=1;i=1000) // Input: String, Output: String "Hello " var objectId = new NodeId(1000, 1); var methodId = new NodeId(62541, 1); var outputs = await session.CallAsync( objectId, methodId, CancellationToken.None, new Variant("World")).ConfigureAwait(false); Assert.That(outputs.Count, Is.GreaterThan(0), "Method should return at least one output"); TestContext.Out.WriteLine($"HelloWorld result: {outputs[0]}"); await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); } [Test, Order(10)] public async Task ConnectWithSecurityBasic256Sha256() { await ConnectWithSecurityPolicyAsync( SecurityPolicies.Basic256Sha256, "Basic256Sha256").ConfigureAwait(false); } [Test, Order(11)] public async Task ConnectWithSecurityAes128Sha256RsaOaep() { await ConnectWithSecurityPolicyAsync( SecurityPolicies.Aes128_Sha256_RsaOaep, "Aes128_Sha256_RsaOaep").ConfigureAwait(false); } [Test, Order(12)] public async Task ConnectWithSecurityAes256Sha256RsaPss() { await ConnectWithSecurityPolicyAsync( SecurityPolicies.Aes256_Sha256_RsaPss, "Aes256_Sha256_RsaPss").ConfigureAwait(false); } [Test, Order(30)] public async Task ConnectWithSecurityEccNistP256() { await ConnectWithSecurityPolicyAsync( "http://opcfoundation.org/UA/SecurityPolicy#ECC_nistP256", "ECC_nistP256", isEcc: true).ConfigureAwait(false); } [Test, Order(31)] public async Task ConnectWithSecurityEccNistP256AesGcm() { await ConnectWithSecurityPolicyAsync( "http://opcfoundation.org/UA/SecurityPolicy#ECC_nistP256_AesGcm", "ECC_nistP256_AesGcm", isEcc: true).ConfigureAwait(false); } [Test, Order(32)] public async Task ConnectWithSecurityEccNistP256ChaChaPoly() { await ConnectWithSecurityPolicyAsync( "http://opcfoundation.org/UA/SecurityPolicy#ECC_nistP256_ChaChaPoly", "ECC_nistP256_ChaChaPoly", isEcc: true).ConfigureAwait(false); } [Test, Order(33)] public async Task ConnectWithSecurityEccNistP384() { await ConnectWithSecurityPolicyAsync( "http://opcfoundation.org/UA/SecurityPolicy#ECC_nistP384", "ECC_nistP384", isEcc: true).ConfigureAwait(false); } [Test, Order(34)] public async Task ConnectWithSecurityEccBrainpoolP256r1() { await ConnectWithSecurityPolicyAsync( "http://opcfoundation.org/UA/SecurityPolicy#ECC_brainpoolP256r1", "ECC_brainpoolP256r1", isEcc: true).ConfigureAwait(false); } [Test, Order(35)] public async Task ConnectWithSecurityEccBrainpoolP384r1() { await ConnectWithSecurityPolicyAsync( "http://opcfoundation.org/UA/SecurityPolicy#ECC_brainpoolP384r1", "ECC_brainpoolP384r1", isEcc: true).ConfigureAwait(false); } [Test, Order(36)] public async Task ConnectWithSecurityEccCurve25519() { await ConnectWithSecurityPolicyAsync( "http://opcfoundation.org/UA/SecurityPolicy#ECC_curve25519", "ECC_curve25519", isEcc: true).ConfigureAwait(false); } [Test, Order(37)] public async Task ConnectWithSecurityEccCurve448() { await ConnectWithSecurityPolicyAsync( "http://opcfoundation.org/UA/SecurityPolicy#ECC_curve448", "ECC_curve448", isEcc: true).ConfigureAwait(false); } [Test, Order(20)] public async Task ConnectUsernameOverEncrypted() { // T-8: Username/Password over Basic256Sha256 var secureEndpoint = await FindSecureEndpointAsync( SecurityPolicies.Basic256Sha256).ConfigureAwait(false); if (secureEndpoint == null) { Assert.Ignore("Server does not offer Basic256Sha256 SignAndEncrypt endpoint"); return; } var configuredEndpoint = new ConfiguredEndpoint(null, secureEndpoint, EndpointConfiguration.Create(_config)); ISession session; try { session = await CreateSessionAsync(configuredEndpoint, new UserIdentity("user1", "password"u8)).ConfigureAwait(false); } catch (ServiceResultException ex) when ( ex.StatusCode == StatusCodes.BadSecurityChecksFailed || ex.StatusCode == StatusCodes.BadCertificateUntrusted || ex.StatusCode == StatusCodes.BadIdentityTokenRejected || ex.StatusCode == StatusCodes.BadIdentityTokenInvalid || ex.StatusCode == StatusCodes.BadUserAccessDenied) { Assert.Ignore( "Server rejected username/certificate (trust or policy not configured). " + $"Status: {ex.StatusCode}"); return; } using (session) { Assert.That(session.Connected, Is.True, "Session should be connected with username over Basic256Sha256"); var serverState = await session.ReadValueAsync( VariableIds.Server_ServerStatus_State, CancellationToken.None).ConfigureAwait(false); Assert.That(serverState.StatusCode, Is.EqualTo(StatusCodes.Good)); TestContext.Out.WriteLine( $"T-8: Username session over {session.Endpoint.SecurityPolicyUri}"); await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); } } [Test, Order(21)] public async Task ConnectWithCertificateAuth() { // T-9: X509 Certificate Authentication var secureEndpoint = await FindSecureEndpointAsync( SecurityPolicies.Basic256Sha256).ConfigureAwait(false); if (secureEndpoint == null) { Assert.Ignore("Server does not offer Basic256Sha256 SignAndEncrypt endpoint"); return; } // Check if the endpoint supports X509 user token bool hasX509Token = false; foreach (var policy in secureEndpoint.UserIdentityTokens) { if (policy.TokenType == UserTokenType.Certificate) { hasX509Token = true; break; } } if (!hasX509Token) { Assert.Ignore("Server does not offer X509 certificate user token"); return; } var configuredEndpoint = new ConfiguredEndpoint(null, secureEndpoint, EndpointConfiguration.Create(_config)); // Use the application instance certificate as user identity var appCert = await _config.SecurityConfiguration .ApplicationCertificate.FindAsync(true).ConfigureAwait(false); if (appCert == null) { Assert.Ignore("No application certificate available for X509 auth"); return; } ISession session; try { session = await CreateSessionAsync(configuredEndpoint, new UserIdentity(appCert)).ConfigureAwait(false); } catch (ServiceResultException ex) when ( ex.StatusCode == StatusCodes.BadSecurityChecksFailed || ex.StatusCode == StatusCodes.BadCertificateUntrusted || ex.StatusCode == StatusCodes.BadIdentityTokenRejected || ex.StatusCode == StatusCodes.BadIdentityTokenInvalid) { Assert.Ignore( "Server rejected X509 identity (trust not configured). " + $"Status: {ex.StatusCode}"); return; } using (session) { Assert.That(session.Connected, Is.True, "Session should be connected with X509 certificate auth"); var serverState = await session.ReadValueAsync( VariableIds.Server_ServerStatus_State, CancellationToken.None).ConfigureAwait(false); Assert.That(serverState.StatusCode, Is.EqualTo(StatusCodes.Good)); TestContext.Out.WriteLine( $"T-9: X509 auth session over {session.Endpoint.SecurityPolicyUri}"); await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); } } // Helper: find a secure endpoint by policy URI private async Task FindSecureEndpointAsync(string policyUri) { var endpointConfig = EndpointConfiguration.Create(_config); endpointConfig.OperationTimeout = 15000; using var discoveryClient = await DiscoveryClient.CreateAsync( new Uri(_serverUrl), endpointConfig, telemetry: _telemetry, ct: CancellationToken.None).ConfigureAwait(false); var endpoints = await discoveryClient.GetEndpointsAsync( default, CancellationToken.None).ConfigureAwait(false); await discoveryClient.CloseAsync(CancellationToken.None).ConfigureAwait(false); foreach (var e in endpoints) { if (e.SecurityPolicyUri == policyUri && e.SecurityMode == MessageSecurityMode.SignAndEncrypt) { return e; } } return null; } // When isEcc, count successful sessions for the safety net. // Curves the C server does not offer (e.g. curve25519 on mbedTLS) // or the .NET SDK cannot connect to still IGNORE gracefully. private async Task ConnectWithSecurityPolicyAsync( string policyUri, string policyName, bool isEcc = false) { var secureEndpoint = await FindSecureEndpointAsync(policyUri) .ConfigureAwait(false); if (secureEndpoint == null) { Assert.Ignore( $"Server does not offer {policyName} SignAndEncrypt endpoint"); return; } var configuredEndpoint = new ConfiguredEndpoint(null, secureEndpoint, EndpointConfiguration.Create(_config)); ISession session; try { session = await CreateSessionAsync(configuredEndpoint) .ConfigureAwait(false); } catch (ServiceResultException ex) when ( ex.StatusCode == StatusCodes.BadSecurityChecksFailed || ex.StatusCode == StatusCodes.BadSecurityPolicyRejected || ex.StatusCode == StatusCodes.BadCertificateUntrusted || ex.StatusCode == StatusCodes.BadCertificateInvalid || ex.StatusCode == StatusCodes.BadCertificateUriInvalid || ex.StatusCode == StatusCodes.BadCertificateHostNameInvalid || ex.StatusCode == StatusCodes.BadCertificateTimeInvalid || ex.StatusCode == StatusCodes.BadCertificateRevoked || ex.StatusCode == StatusCodes.BadCertificateIssuerRevoked || ex.StatusCode == StatusCodes.BadCertificateUseNotAllowed || ex.StatusCode == StatusCodes.BadCertificatePolicyCheckFailed) { Assert.Ignore( $"Server rejected client certificate for {policyName}. " + $"Status: {ex.StatusCode}"); return; } catch (ServiceResultException ex) { // Catch-all for remaining ServiceResultExceptions // (e.g. missing application certificate for ECC curves // not supported by the .NET SDK like curve25519/curve448) Assert.Ignore( $"Cannot connect with {policyName}: {ex.Message}"); return; } using (session) { Assert.That(session.Connected, Is.True, $"Session should be connected with {policyName}"); var serverState = await session.ReadValueAsync( VariableIds.Server_ServerStatus_State, CancellationToken.None).ConfigureAwait(false); Assert.That(serverState.StatusCode, Is.EqualTo(StatusCodes.Good)); TestContext.Out.WriteLine( $"Secure session established with {session.Endpoint.SecurityPolicyUri}"); if (isEcc) { lock (s_eccLock) { s_eccTestsPassed++; } } await session.CloseAsync(CancellationToken.None).ConfigureAwait(false); } } /// /// Ensure all ECC application certificates exist. /// The SDK's CheckApplicationInstanceCertificatesAsync may fail to /// auto-generate P384 or brainpool certs on some platforms. /// This method fills in any gaps using System.Security.Cryptography. /// private async Task EnsureEccCertificatesAsync() { var appCerts = _config.SecurityConfiguration.ApplicationCertificates; if (appCerts == null) return; foreach (var certId in appCerts) { if (!TryGetEccCurve(certId.CertificateType, out var curve, out var hashAlg)) continue; var existing = await certId.FindAsync(true, telemetry: _telemetry) .ConfigureAwait(false); if (existing != null && existing.HasPrivateKey) { TestContext.Out.WriteLine( $"ECC cert OK: {certId.CertificateType} ({existing.Subject})"); // Do NOT dispose — the SDK caches this in certId.Certificate continue; } // If the cert exists but has no private key, we need to regenerate // Do NOT dispose — let the SDK manage the lifecycle TestContext.Out.WriteLine( $"ECC cert missing, generating: {certId.CertificateType}"); try { var cert = CreateSelfSignedEccCert( curve, hashAlg, Utils.ReplaceDCLocalhost(certId.SubjectName), _config.ApplicationUri); // Write to the PKI directory store so the SDK can find it string certsDir = Path.Combine(_pkiRoot, "certs"); string privateDir = Path.Combine(_pkiRoot, "private"); Directory.CreateDirectory(certsDir); Directory.CreateDirectory(privateDir); string tp = cert.Thumbprint; await File.WriteAllBytesAsync( Path.Combine(privateDir, $"{tp}.pfx"), cert.Export(X509ContentType.Pfx, (string?)null)) .ConfigureAwait(false); await File.WriteAllBytesAsync( Path.Combine(certsDir, $"{tp}.der"), cert.Export(X509ContentType.Cert)) .ConfigureAwait(false); TestContext.Out.WriteLine( $"Generated ECC cert: {certId.CertificateType} " + $"({cert.Subject}, {tp})"); cert.Dispose(); } catch (Exception ex) { TestContext.Out.WriteLine( $"Cannot generate ECC cert {certId.CertificateType}: " + $"{ex.Message}"); } } } private static bool TryGetEccCurve( NodeId? certType, out ECCurve curve, out HashAlgorithmName hashAlg) { curve = default; hashAlg = HashAlgorithmName.SHA256; if (certType == null) return false; if (certType == ObjectTypeIds.EccNistP256ApplicationCertificateType) { curve = ECCurve.NamedCurves.nistP256; return true; } if (certType == ObjectTypeIds.EccNistP384ApplicationCertificateType) { curve = ECCurve.NamedCurves.nistP384; hashAlg = HashAlgorithmName.SHA384; return true; } if (certType == ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType) { // brainpoolP256r1 OID curve = ECCurve.CreateFromValue("1.3.36.3.3.2.8.1.1.7"); return true; } if (certType == ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType) { // brainpoolP384r1 OID curve = ECCurve.CreateFromValue("1.3.36.3.3.2.8.1.1.11"); hashAlg = HashAlgorithmName.SHA384; return true; } return false; } private static X509Certificate2 CreateSelfSignedEccCert( ECCurve curve, HashAlgorithmName hashAlg, string subjectName, string applicationUri) { using var key = ECDsa.Create(curve); var req = new CertificateRequest(subjectName, key, hashAlg); var sanBuilder = new SubjectAlternativeNameBuilder(); sanBuilder.AddUri(new Uri(applicationUri)); sanBuilder.AddDnsName("localhost"); sanBuilder.AddIpAddress(IPAddress.Parse("127.0.0.1")); req.CertificateExtensions.Add(sanBuilder.Build()); req.CertificateExtensions.Add( new X509BasicConstraintsExtension(false, false, 0, true)); req.CertificateExtensions.Add( new X509KeyUsageExtension( X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.NonRepudiation | X509KeyUsageFlags.KeyAgreement, true)); var cert = req.CreateSelfSigned( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); // Re-import so the private key is exportable return X509CertificateLoader.LoadPkcs12( cert.Export(X509ContentType.Pfx, (string?)null), (string?)null, X509KeyStorageFlags.Exportable); } } [TestFixture] [Category("Interop")] public sealed class InteropClientTest : InteropClientTestBase { protected override string ServerUrlEnvironmentVariable => "OPCUA_INTEROP_SERVER_URL"; protected override string DefaultServerUrl => "opc.tcp://localhost:4840"; } }