mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 00:31:32 +08:00
440 lines
20 KiB
C#
440 lines
20 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
// CA2007: tests run without a SynchronizationContext; ConfigureAwait(false)
|
|
// adds noise without a behavioural benefit. Disabled file-level for the suite.
|
|
#pragma warning disable CA2007
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Security.Cryptography;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client;
|
|
using Opc.Ua.Configuration;
|
|
using Opc.Ua.Security.Certificates;
|
|
using Opc.Ua.Server.TestFramework;
|
|
using Opc.Ua.Tests;
|
|
using Quickstarts.ReferenceServer;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
/// <summary>
|
|
/// Tests for CreateSession client certificate ApplicationUri validation.
|
|
/// Validates that StandardServer.CreateSession correctly verifies that the ApplicationUri
|
|
/// in the ApplicationDescription parameter matches the URIs in the client certificate.
|
|
/// Tests include single URI, multiple URIs, matching and non-matching scenarios.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[Category("Server")]
|
|
[SetCulture("en-us")]
|
|
[SetUICulture("en-us")]
|
|
[Parallelizable]
|
|
public class CreateSessionApplicationUriValidationTests
|
|
{
|
|
private const string kClientApplicationUri = "urn:localhost:opcfoundation.org:TestClient";
|
|
private const string kClientSubjectName = "CN=TestClient, O=OPC Foundation";
|
|
private ServerFixture<StandardServer> m_serverFixture;
|
|
private string m_pkiRoot;
|
|
|
|
/// <summary>
|
|
/// Certificate types to test.
|
|
/// Note: Currently only testing RSA certificates. ECC certificates require ECC-compatible
|
|
/// security policies which add complexity beyond the scope of ApplicationUri validation testing.
|
|
/// </summary>
|
|
private static readonly NodeId[] s_certificateTypes =
|
|
[
|
|
ObjectTypeIds.RsaMinApplicationCertificateType
|
|
];
|
|
|
|
[OneTimeSetUp]
|
|
public async Task OneTimeSetUpAsync()
|
|
{
|
|
m_pkiRoot = Path.GetTempPath() + Path.GetRandomFileName() + Path.DirectorySeparatorChar;
|
|
|
|
// Start a server for testing CreateSession
|
|
m_serverFixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t))
|
|
{
|
|
AutoAccept = true,
|
|
AllNodeManagers = true,
|
|
SecurityNone = false // Require encryption to enable certificate validation
|
|
};
|
|
|
|
await m_serverFixture.LoadConfigurationAsync(m_pkiRoot).ConfigureAwait(false);
|
|
await m_serverFixture.StartAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
[OneTimeTearDown]
|
|
public async Task OneTimeTearDownAsync()
|
|
{
|
|
if (m_serverFixture != null)
|
|
{
|
|
await m_serverFixture.StopAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
try
|
|
{
|
|
if (Directory.Exists(m_pkiRoot))
|
|
{
|
|
Directory.Delete(m_pkiRoot, true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that CreateSession succeeds when client certificate has matching ApplicationUri.
|
|
/// </summary>
|
|
[Test]
|
|
[TestCaseSource(nameof(s_certificateTypes))]
|
|
public async Task CreateSessionWithMatchingApplicationUriSucceedsAsync(NodeId certificateType)
|
|
{
|
|
// Skip test if certificate type is not supported on this platform
|
|
if (!Utils.IsSupportedCertificateType(certificateType))
|
|
{
|
|
Assert.Ignore($"Certificate type {certificateType} is not supported on this platform.");
|
|
}
|
|
|
|
// Create client certificate with matching ApplicationUri
|
|
using Certificate clientCert = CreateCertificateWithMultipleUris(
|
|
[kClientApplicationUri],
|
|
kClientSubjectName,
|
|
[Utils.GetHostName()],
|
|
certificateType);
|
|
|
|
// Attempt to create session - should succeed
|
|
Client.ISession session = await CreateSessionWithCustomCertificateAsync(clientCert, kClientApplicationUri).ConfigureAwait(false);
|
|
Assert.That(session, Is.Not.Null);
|
|
Assert.That(session.Connected, Is.True, "Session should be connected");
|
|
|
|
// Verify session is functional by reading server state
|
|
DataValue result = await session.ReadValueAsync(VariableIds.Server_ServerStatus_State).ConfigureAwait(false);
|
|
Assert.That(result.IsNull, Is.False, "Should be able to read server state");
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good), "Read operation should succeed");
|
|
|
|
await session.CloseAsync(5_000, true).ConfigureAwait(false);
|
|
session.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that CreateSession throws BadCertificateUriInvalid when client certificate ApplicationUri doesn't match.
|
|
/// </summary>
|
|
[Test]
|
|
[TestCaseSource(nameof(s_certificateTypes))]
|
|
public void CreateSessionWithMismatchedApplicationUriThrows(NodeId certificateType)
|
|
{
|
|
// Skip test if certificate type is not supported on this platform
|
|
if (!Utils.IsSupportedCertificateType(certificateType))
|
|
{
|
|
Assert.Ignore($"Certificate type {certificateType} is not supported on this platform.");
|
|
}
|
|
|
|
// Create client certificate with different ApplicationUri
|
|
const string certUri = "urn:localhost:opcfoundation.org:WrongClient";
|
|
using Certificate clientCert = CreateCertificateWithMultipleUris(
|
|
[certUri],
|
|
kClientSubjectName,
|
|
[Utils.GetHostName()],
|
|
certificateType);
|
|
|
|
// Attempt to create session - should throw BadCertificateUriInvalid
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
await CreateSessionWithCustomCertificateAsync(clientCert, kClientApplicationUri).ConfigureAwait(false));
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadCertificateUriInvalid));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that CreateSession succeeds when client certificate has multiple URIs and one matches.
|
|
/// </summary>
|
|
[Test]
|
|
[TestCaseSource(nameof(s_certificateTypes))]
|
|
public async Task CreateSessionWithMultipleUrisOneMatchesSucceedsAsync(NodeId certificateType)
|
|
{
|
|
// Skip test if certificate type is not supported on this platform
|
|
if (!Utils.IsSupportedCertificateType(certificateType))
|
|
{
|
|
Assert.Ignore($"Certificate type {certificateType} is not supported on this platform.");
|
|
}
|
|
|
|
// Create client certificate with multiple URIs, including the matching one
|
|
const string uri1 = "urn:localhost:opcfoundation.org:App1";
|
|
const string uri2 = kClientApplicationUri; // This matches
|
|
const string uri3 = "https://localhost:8080/OpcUaApp";
|
|
|
|
using Certificate clientCert = CreateCertificateWithMultipleUris(
|
|
[uri1, uri2, uri3],
|
|
kClientSubjectName,
|
|
[Utils.GetHostName()],
|
|
certificateType);
|
|
|
|
// Verify certificate has multiple URIs
|
|
IReadOnlyList<string> uris = X509Utils.GetApplicationUrisFromCertificate(clientCert);
|
|
Assert.That(uris, Has.Count.EqualTo(3));
|
|
Assert.Contains(uri1, uris.ToList());
|
|
Assert.Contains(uri2, uris.ToList());
|
|
Assert.Contains(uri3, uris.ToList());
|
|
|
|
// Attempt to create session - should succeed because one URI matches
|
|
Client.ISession session = await CreateSessionWithCustomCertificateAsync(clientCert, kClientApplicationUri).ConfigureAwait(false);
|
|
Assert.That(session, Is.Not.Null);
|
|
Assert.That(session.Connected, Is.True, "Session should be connected");
|
|
|
|
// Verify session is functional by reading server state
|
|
DataValue result = await session.ReadValueAsync(VariableIds.Server_ServerStatus_State).ConfigureAwait(false);
|
|
Assert.That(result.IsNull, Is.False, "Should be able to read server state");
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good), "Read operation should succeed");
|
|
|
|
await session.CloseAsync(5_000, true).ConfigureAwait(false);
|
|
session.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test that CreateSession throws BadCertificateUriInvalid when client certificate has multiple URIs but none match.
|
|
/// </summary>
|
|
[Test]
|
|
[TestCaseSource(nameof(s_certificateTypes))]
|
|
public void CreateSessionWithMultipleUrisNoneMatchThrows(NodeId certificateType)
|
|
{
|
|
// Skip test if certificate type is not supported on this platform
|
|
if (!Utils.IsSupportedCertificateType(certificateType))
|
|
{
|
|
Assert.Ignore($"Certificate type {certificateType} is not supported on this platform.");
|
|
}
|
|
|
|
// Create client certificate with multiple URIs, none matching
|
|
const string uri1 = "urn:localhost:opcfoundation.org:App1";
|
|
const string uri2 = "urn:localhost:opcfoundation.org:App2";
|
|
const string uri3 = "https://localhost:8080/OpcUaApp";
|
|
|
|
using Certificate clientCert = CreateCertificateWithMultipleUris(
|
|
[uri1, uri2, uri3],
|
|
kClientSubjectName,
|
|
[Utils.GetHostName()],
|
|
certificateType);
|
|
|
|
// Verify certificate has multiple URIs
|
|
IReadOnlyList<string> uris = X509Utils.GetApplicationUrisFromCertificate(clientCert);
|
|
Assert.That(uris, Has.Count.EqualTo(3));
|
|
Assert.Contains(uri1, uris.ToList());
|
|
Assert.Contains(uri2, uris.ToList());
|
|
Assert.Contains(uri3, uris.ToList());
|
|
|
|
// Attempt to create session - should throw BadCertificateUriInvalid
|
|
ServiceResultException ex = Assert.ThrowsAsync<ServiceResultException>(async () =>
|
|
await CreateSessionWithCustomCertificateAsync(clientCert, kClientApplicationUri).ConfigureAwait(false));
|
|
Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadCertificateUriInvalid));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Helper method to create a session with a custom client certificate.
|
|
/// </summary>
|
|
private async Task<Client.ISession> CreateSessionWithCustomCertificateAsync(
|
|
Certificate clientCertificate,
|
|
string clientApplicationUri)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ILogger logger = telemetry.CreateLogger<CreateSessionApplicationUriValidationTests>();
|
|
|
|
// Create temporary PKI directory
|
|
string clientPkiRoot = Path.GetTempPath() + Path.GetRandomFileName() + Path.DirectorySeparatorChar;
|
|
Directory.CreateDirectory(clientPkiRoot);
|
|
|
|
try
|
|
{
|
|
// Save certificate to a temporary store
|
|
string certStorePath = Path.Combine(clientPkiRoot, "own");
|
|
Directory.CreateDirectory(certStorePath);
|
|
|
|
using (ICertificateStore store = CertificateStoreIdentifier.CreateStore(CertificateStoreType.Directory, telemetry))
|
|
{
|
|
store.Open(certStorePath, false);
|
|
await store.AddAsync(clientCertificate).ConfigureAwait(false);
|
|
}
|
|
|
|
// Create certificate identifier pointing to the stored
|
|
// certificate. The identifier is metadata-only — the
|
|
// resolver loads the cert from the store on demand.
|
|
var certIdentifier = new CertificateIdentifier
|
|
{
|
|
StoreType = CertificateStoreType.Directory,
|
|
StorePath = certStorePath,
|
|
SubjectName = clientCertificate.SubjectName.Name,
|
|
Thumbprint = clientCertificate.Thumbprint,
|
|
CertificateType = CertificateIdentifier.GetCertificateType(clientCertificate)
|
|
};
|
|
|
|
// Create client application configuration
|
|
var clientApp = new ApplicationInstance(telemetry)
|
|
{
|
|
ApplicationName = "TestClient",
|
|
ApplicationType = ApplicationType.Client
|
|
};
|
|
await using (clientApp.ConfigureAwait(false))
|
|
{
|
|
ApplicationConfiguration clientConfig = await clientApp
|
|
.Build(clientApplicationUri, "uri:opcfoundation.org:TestClient")
|
|
.AsClient()
|
|
.AddSecurityConfiguration([certIdentifier], clientPkiRoot)
|
|
.SetMinimumCertificateKeySize(256)
|
|
.SetAutoAcceptUntrustedCertificates(true)
|
|
.CreateAsync()
|
|
.ConfigureAwait(false);
|
|
|
|
// Get server endpoint with RSA-compatible security policy
|
|
EndpointDescription endpoint = m_serverFixture.Server.GetEndpoints()
|
|
.Find(e => e.SecurityMode == MessageSecurityMode.SignAndEncrypt &&
|
|
e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256);
|
|
|
|
Assert.That(endpoint, Is.Not.Null, "No suitable endpoint found");
|
|
|
|
var endpointConfiguration = EndpointConfiguration.Create(clientConfig);
|
|
endpointConfiguration.OperationTimeout = 10000;
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfiguration);
|
|
|
|
// Create and open session with retry logic for transient errors
|
|
var sessionFactory = new DefaultSessionFactory(telemetry);
|
|
const int maxAttempts = 40;
|
|
const int delayMs = 5000;
|
|
for (int attempt = 0; ; attempt++)
|
|
{
|
|
try
|
|
{
|
|
return await sessionFactory.CreateAsync(
|
|
clientConfig,
|
|
configuredEndpoint,
|
|
false, // updateBeforeConnect
|
|
false, // checkDomain
|
|
"TestSession",
|
|
60000, // sessionTimeout
|
|
null, // userIdentity
|
|
default) // preferredLocales
|
|
.ConfigureAwait(false);
|
|
}
|
|
catch (ServiceResultException e) when (
|
|
(
|
|
e.StatusCode == StatusCodes.BadServerHalted ||
|
|
e.StatusCode == StatusCodes.BadSecureChannelClosed ||
|
|
e.StatusCode == StatusCodes.BadNoCommunication ||
|
|
e.StatusCode == StatusCodes.BadNotConnected
|
|
) &&
|
|
attempt < maxAttempts)
|
|
{
|
|
// Retry for transient connection errors (can happen on busy CI environments)
|
|
logger.LogWarning(
|
|
e,
|
|
"Failed to create session (attempt {Attempt}/{MaxAttempts}). Retrying in {DelayMs}ms... Error: {StatusCode}",
|
|
attempt + 1,
|
|
maxAttempts,
|
|
delayMs,
|
|
e.Code);
|
|
await Task.Delay(delayMs).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
// Clean up temp PKI
|
|
try
|
|
{
|
|
if (Directory.Exists(clientPkiRoot))
|
|
{
|
|
Directory.Delete(clientPkiRoot, true);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a certificate with multiple application URIs in the SAN extension.
|
|
/// </summary>
|
|
private static Certificate CreateCertificateWithMultipleUris(
|
|
IList<string> applicationUris,
|
|
string subjectName,
|
|
IList<string> domainNames,
|
|
NodeId certificateType = default)
|
|
{
|
|
DateTime notBefore = DateTime.Today.AddDays(-1);
|
|
DateTime notAfter = DateTime.Today.AddYears(1);
|
|
|
|
// Default to RSA if not specified
|
|
if (certificateType.IsNull)
|
|
{
|
|
certificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType;
|
|
}
|
|
// Create the SAN extension with multiple URIs
|
|
var subjectAltName = new X509SubjectAltNameExtension(applicationUris, domainNames);
|
|
|
|
// Build the certificate with the custom SAN extension
|
|
ICertificateBuilder builder = CertificateBuilder
|
|
.Create(subjectName)
|
|
.SetNotBefore(notBefore)
|
|
.SetNotAfter(notAfter)
|
|
.AddExtension(subjectAltName);
|
|
|
|
// Create certificate based on type
|
|
if (certificateType == ObjectTypeIds.RsaSha256ApplicationCertificateType ||
|
|
certificateType == ObjectTypeIds.RsaMinApplicationCertificateType)
|
|
{
|
|
return builder.SetRSAKeySize(CertificateFactory.DefaultKeySize).CreateForRSA();
|
|
}
|
|
else if (certificateType == ObjectTypeIds.EccNistP256ApplicationCertificateType)
|
|
{
|
|
return builder.SetECCurve(ECCurve.NamedCurves.nistP256).CreateForECDsa();
|
|
}
|
|
else if (certificateType == ObjectTypeIds.EccNistP384ApplicationCertificateType)
|
|
{
|
|
return builder.SetECCurve(ECCurve.NamedCurves.nistP384).CreateForECDsa();
|
|
}
|
|
else if (certificateType == ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType)
|
|
{
|
|
return builder.SetECCurve(ECCurve.NamedCurves.brainpoolP256r1).CreateForECDsa();
|
|
}
|
|
else if (certificateType == ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType)
|
|
{
|
|
return builder.SetECCurve(ECCurve.NamedCurves.brainpoolP384r1).CreateForECDsa();
|
|
}
|
|
else
|
|
{
|
|
// Default to RSA for unknown types
|
|
return builder.SetRSAKeySize(CertificateFactory.DefaultKeySize).CreateForRSA();
|
|
}
|
|
}
|
|
}
|
|
}
|