diff --git a/Libraries/Opc.Ua.Client/ReverseConnectManager.cs b/Libraries/Opc.Ua.Client/ReverseConnectManager.cs
index 362ebabe70..d489f67392 100644
--- a/Libraries/Opc.Ua.Client/ReverseConnectManager.cs
+++ b/Libraries/Opc.Ua.Client/ReverseConnectManager.cs
@@ -153,15 +153,21 @@ public Registration(
}
///
- /// Register with the server certificate.
+ /// Register with the server certificate to extract the application Uri.
///
+ ///
+ /// The first Uri in the subject alternate name field is considered the application Uri.
+ ///
+ /// The server certificate with the application Uri.
+ /// The endpoint Url of the server.
+ /// The connection to use.
public Registration(
X509Certificate2 serverCertificate,
Uri endpointUrl,
EventHandler onConnectionWaiting)
: this(endpointUrl, onConnectionWaiting)
{
- ServerUri = X509Utils.GetApplicationUriFromCertificate(serverCertificate);
+ ServerUri = X509Utils.GetApplicationUrisFromCertificate(serverCertificate).FirstOrDefault();
}
private Registration(
diff --git a/Libraries/Opc.Ua.Client/Session/Session.cs b/Libraries/Opc.Ua.Client/Session/Session.cs
index 91dda0d06f..97c3834f9f 100644
--- a/Libraries/Opc.Ua.Client/Session/Session.cs
+++ b/Libraries/Opc.Ua.Client/Session/Session.cs
@@ -1707,8 +1707,6 @@ public async Task OpenAsync(
if (requireEncryption)
{
- // validation skipped until IOP isses are resolved.
- // ValidateServerCertificateApplicationUri(serverCertificate);
if (checkDomain)
{
await m_configuration
@@ -1836,6 +1834,8 @@ await m_configuration
ValidateServerEndpoints(serverEndpoints);
+ ValidateServerCertificateApplicationUri(serverCertificate, m_endpoint);
+
ValidateServerSignature(
serverCertificate,
serverSignature,
@@ -6352,31 +6352,6 @@ private void OpenValidateIdentity(
}
}
-#if UNUSED
- ///
- /// Validates the ServerCertificate ApplicationUri to match the ApplicationUri of the Endpoint
- /// for an open call (Spec Part 4 5.4.1)
- ///
- private void ValidateServerCertificateApplicationUri(X509Certificate2 serverCertificate)
- {
- string applicationUri = m_endpoint?.Description?.Server?.ApplicationUri;
- //check is only neccessary if the ApplicatioUri is specified for the Endpoint
- if (string.IsNullOrEmpty(applicationUri))
- {
- throw ServiceResultException.Create(
- StatusCodes.BadSecurityChecksFailed,
- "No ApplicationUri is specified for the server in the EndpointDescription.");
- }
- string certificateApplicationUri = X509Utils.GetApplicationUriFromCertificate(serverCertificate);
- if (!string.Equals(certificateApplicationUri, applicationUri, StringComparison.Ordinal))
- {
- throw ServiceResultException.Create(
- StatusCodes.BadSecurityChecksFailed,
- "Server did not return a Certificate matching the ApplicationUri specified in the EndpointDescription.");
- }
- }
-#endif
-
private void BuildCertificateData(
out byte[] clientCertificateData,
out byte[] clientCertificateChainData)
@@ -6489,6 +6464,20 @@ private void ValidateServerSignature(
}
}
+ ///
+ /// Validates the ServerCertificate ApplicationUri to match the ApplicationUri
+ /// of the Endpoint (Spec Part 4 5.4.1) returned by the CreateSessionResponse.
+ /// Ensure the endpoint was matched in
+ /// with the applicationUri of the server description before the validation.
+ ///
+ private void ValidateServerCertificateApplicationUri(X509Certificate2 serverCertificate, ConfiguredEndpoint endpoint)
+ {
+ if (serverCertificate != null)
+ {
+ m_configuration.CertificateValidator.ValidateApplicationUri(serverCertificate, endpoint);
+ }
+ }
+
///
/// Validates the server endpoints returned.
///
diff --git a/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs b/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs
index ea9d110f93..3247d42dd5 100644
--- a/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs
+++ b/Libraries/Opc.Ua.Configuration/ApplicationInstance.cs
@@ -358,11 +358,16 @@ public async ValueTask CheckApplicationInstanceCertificatesAsync(
"Need at least one Application Certificate.");
}
+ // Note: The FindAsync method searches certificates in this order: thumbprint, subjectName, then applicationUri.
+ // When SubjectName or Thumbprint is specified, certificates may be loaded even if their ApplicationUri
+ // doesn't match ApplicationConfiguration.ApplicationUri, however each certificate is validated individually
+ // in CheckApplicationInstanceCertificateAsync (called via CheckOrCreateCertificateAsync) to ensure it contains
+ // the configuration's ApplicationUri.
bool result = true;
foreach (CertificateIdentifier certId in securityConfiguration.ApplicationCertificates)
{
ushort minimumKeySize = certId.GetMinKeySize(securityConfiguration);
- bool nextResult = await CheckCertificateTypeAsync(
+ bool nextResult = await CheckOrCreateCertificateAsync(
certId,
silent,
minimumKeySize,
@@ -376,10 +381,14 @@ public async ValueTask CheckApplicationInstanceCertificatesAsync(
}
///
- /// Check certificate type
+ /// Checks, validates, and optionally creates an application certificate.
+ /// Loads the certificate, validates it against configured requirements (ApplicationUri, key size, domains),
+ /// and creates a new certificate if none exists and auto-creation is enabled.
+ /// Note: FindAsync searches certificates in order: thumbprint, subjectName, applicationUri.
+ /// The applicationUri parameter is only used if thumbprint and subjectName don't find a match.
///
///
- private async Task CheckCertificateTypeAsync(
+ private async Task CheckOrCreateCertificateAsync(
CertificateIdentifier id,
bool silent,
ushort minimumKeySize,
@@ -402,7 +411,7 @@ private async Task CheckCertificateTypeAsync(
await id.LoadPrivateKeyExAsync(passwordProvider, configuration.ApplicationUri, m_telemetry, ct)
.ConfigureAwait(false);
- // load the certificate
+ // load the certificate
X509Certificate2 certificate = await id.FindAsync(
true,
configuration.ApplicationUri,
@@ -738,28 +747,37 @@ await configuration
return false;
}
- // check uri.
- string applicationUri = X509Utils.GetApplicationUriFromCertificate(certificate);
-
- if (string.IsNullOrEmpty(applicationUri))
+ // Validate that the certificate contains the configuration's ApplicationUri
+ if (!X509Utils.CompareApplicationUriWithCertificate(certificate, configuration.ApplicationUri, out var certificateUris))
{
- const string message =
- "The Application URI could not be read from the certificate. Use certificate anyway?";
- if (!await ApproveMessageAsync(message, silent).ConfigureAwait(false))
+ if (certificateUris.Count == 0)
{
- return false;
+ const string message =
+ "The Application URI could not be found in the certificate. Use certificate anyway?";
+ if (!await ApproveMessageAsync(message, silent).ConfigureAwait(false))
+ {
+ return false;
+ }
+ }
+ else
+ {
+ string message = Utils.Format(
+ "The certificate with subject '{0}' does not contain the ApplicationUri '{1}' from the configuration. Certificate contains: {2}. Use certificate anyway?",
+ certificate.Subject,
+ configuration.ApplicationUri,
+ string.Join(", ", certificateUris));
+
+ if (!await ApproveMessageAsync(message, silent).ConfigureAwait(false))
+ {
+ return false;
+ }
}
- }
- else if (!configuration.ApplicationUri.Equals(applicationUri, StringComparison.Ordinal))
- {
- m_logger.LogInformation(
- "Updated the ApplicationUri: {PreviousApplicationUri} --> {NewApplicationUri}",
- configuration.ApplicationUri,
- applicationUri);
- configuration.ApplicationUri = applicationUri;
}
- m_logger.LogInformation("Using the ApplicationUri: {ApplicationUri}", applicationUri);
+ m_logger.LogInformation(
+ "Certificate {Certificate} validated for ApplicationUri: {ApplicationUri}",
+ certificate.AsLogSafeString(),
+ configuration.ApplicationUri);
// update configuration.
id.Certificate = certificate;
diff --git a/Libraries/Opc.Ua.Gds.Client.Common/CertificateWrapper.cs b/Libraries/Opc.Ua.Gds.Client.Common/CertificateWrapper.cs
index 87159c9635..70694cf519 100644
--- a/Libraries/Opc.Ua.Gds.Client.Common/CertificateWrapper.cs
+++ b/Libraries/Opc.Ua.Gds.Client.Common/CertificateWrapper.cs
@@ -29,6 +29,7 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Runtime.Serialization;
using System.Security.Cryptography.X509Certificates;
@@ -202,7 +203,7 @@ public string ApplicationUri
{
try
{
- return X509Utils.GetApplicationUriFromCertificate(Certificate);
+ return X509Utils.GetApplicationUrisFromCertificate(Certificate).FirstOrDefault();
}
catch (Exception e)
{
diff --git a/Libraries/Opc.Ua.Security.Certificates/Extensions/X509SubjectAltNameExtension.cs b/Libraries/Opc.Ua.Security.Certificates/Extensions/X509SubjectAltNameExtension.cs
index a12e09441a..e34c0141c8 100644
--- a/Libraries/Opc.Ua.Security.Certificates/Extensions/X509SubjectAltNameExtension.cs
+++ b/Libraries/Opc.Ua.Security.Certificates/Extensions/X509SubjectAltNameExtension.cs
@@ -116,7 +116,23 @@ public X509SubjectAltNameExtension(string applicationUri, IEnumerable do
{
Oid = new Oid(SubjectAltName2Oid, kFriendlyName);
Critical = false;
- Initialize(applicationUri, domainNames);
+ Initialize(new string[] { applicationUri }, domainNames);
+ RawData = Encode();
+ m_decoded = true;
+ }
+
+ ///
+ /// Build the Subject Alternative name extension (for OPC UA application certs).
+ ///
+ /// The application Uri
+ /// The domain names. DNS Hostnames, IPv4 or IPv6 addresses
+ public X509SubjectAltNameExtension(
+ IEnumerable applicationUris,
+ IEnumerable domainNames)
+ {
+ Oid = new Oid(SubjectAltName2Oid, kFriendlyName);
+ Critical = false;
+ Initialize(applicationUris, domainNames);
RawData = Encode();
m_decoded = true;
}
@@ -408,14 +424,17 @@ private void Decode(byte[] data)
///
/// Initialize the Subject Alternative name extension.
///
- /// The application Uri
+ /// The application Uris
/// The general names. DNS Hostnames, IPv4 or IPv6 addresses
- private void Initialize(string applicationUri, IEnumerable generalNames)
+ private void Initialize(IEnumerable applicationUris, IEnumerable generalNames)
{
var uris = new List();
var domainNames = new List();
var ipAddresses = new List();
- uris.Add(applicationUri);
+ foreach (string applicationUri in applicationUris)
+ {
+ uris.Add(applicationUri);
+ }
foreach (string generalName in generalNames)
{
switch (Uri.CheckHostName(generalName))
diff --git a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs
index 9f28144868..a63282d1a4 100644
--- a/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs
+++ b/Libraries/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs
@@ -529,8 +529,8 @@ private async ValueTask UpdateCertificateAsy
cert.CertificateType == certificateTypeId)
?? certificateGroup.ApplicationCertificates.FirstOrDefault(cert =>
cert.Certificate != null &&
- m_configuration.ApplicationUri ==
- X509Utils.GetApplicationUriFromCertificate(cert.Certificate) &&
+ X509Utils.GetApplicationUrisFromCertificate(cert.Certificate)
+ .Any(uri => uri.Equals(m_configuration.ApplicationUri, StringComparison.Ordinal)) &&
cert.CertificateType == certificateTypeId))
?? throw new ServiceResultException(
StatusCodes.BadInvalidArgument,
diff --git a/Libraries/Opc.Ua.Server/Server/StandardServer.cs b/Libraries/Opc.Ua.Server/Server/StandardServer.cs
index 0a50354036..5f674891f4 100644
--- a/Libraries/Opc.Ua.Server/Server/StandardServer.cs
+++ b/Libraries/Opc.Ua.Server/Server/StandardServer.cs
@@ -380,31 +380,27 @@ X509Certificate2Collection clientCertificateChain
if (context.SecurityPolicyUri != SecurityPolicies.None)
{
- string certificateApplicationUri = X509Utils
- .GetApplicationUriFromCertificate(
- parsedClientCertificate);
-
- // verify if applicationUri from ApplicationDescription matches the applicationUri in the client certificate.
- if (!string.IsNullOrEmpty(certificateApplicationUri) &&
- !string.IsNullOrEmpty(clientDescription.ApplicationUri) &&
- certificateApplicationUri != clientDescription.ApplicationUri)
+ // verify if applicationUri from ApplicationDescription matches the applicationUris in the client certificate.
+ if (!string.IsNullOrEmpty(clientDescription.ApplicationUri))
{
- // report the AuditCertificateDataMismatch event for invalid uri
- ServerInternal?.ReportAuditCertificateDataMismatchEvent(
- parsedClientCertificate,
- null,
- clientDescription.ApplicationUri,
- StatusCodes.BadCertificateUriInvalid,
- m_logger);
+ if (!X509Utils.CompareApplicationUriWithCertificate(parsedClientCertificate, clientDescription.ApplicationUri))
+ {
+ // report the AuditCertificateDataMismatch event for invalid uri
+ ServerInternal?.ReportAuditCertificateDataMismatchEvent(
+ parsedClientCertificate,
+ null,
+ clientDescription.ApplicationUri,
+ StatusCodes.BadCertificateUriInvalid,
+ m_logger);
+
+ throw ServiceResultException.Create(
+ StatusCodes.BadCertificateUriInvalid,
+ "The URI specified in the ApplicationDescription {0} does not match the URIs in the Certificate.",
+ clientDescription.ApplicationUri);
+ }
- throw ServiceResultException.Create(
- StatusCodes.BadCertificateUriInvalid,
- "The URI specified in the ApplicationDescription {0} does not match the URI in the Certificate: {1}.",
- clientDescription.ApplicationUri,
- certificateApplicationUri);
+ CertificateValidator.Validate(clientCertificateChain);
}
-
- CertificateValidator.Validate(clientCertificateChain);
}
}
catch (Exception e)
diff --git a/Stack/Opc.Ua.Core/Schema/ApplicationConfiguration.cs b/Stack/Opc.Ua.Core/Schema/ApplicationConfiguration.cs
index 75e0b59c91..7943c8360e 100644
--- a/Stack/Opc.Ua.Core/Schema/ApplicationConfiguration.cs
+++ b/Stack/Opc.Ua.Core/Schema/ApplicationConfiguration.cs
@@ -722,33 +722,58 @@ public CertificateIdentifierCollection ApplicationCertificates
get => m_applicationCertificates;
set
{
- m_applicationCertificates = value ?? [];
+ if (value == null || value.Count == 0)
+ {
+ m_applicationCertificates = new CertificateIdentifierCollection();
+ return;
+ }
- IsDeprecatedConfiguration = false;
+ var newCertificates = new CertificateIdentifierCollection(value);
- // Remove any unsupported certificate types.
- for (int i = m_applicationCertificates.Count - 1; i >= 0; i--)
+ // Remove unsupported certificate types
+ for (int i = newCertificates.Count - 1; i >= 0; i--)
{
- if (!Utils.IsSupportedCertificateType(
- m_applicationCertificates[i].CertificateType))
+ if (!Utils.IsSupportedCertificateType(newCertificates[i].CertificateType))
{
- m_applicationCertificates.RemoveAt(i);
+ // TODO: Log when ITelemetry instance is available
+ newCertificates.RemoveAt(i);
}
}
- // Remove any duplicates
- for (int i = 0; i < m_applicationCertificates.Count; i++)
+ // Remove any duplicates based on thumbprint
+ // Only perform duplicate detection if we have actual loaded certificates
+ for (int i = 0; i < newCertificates.Count; i++)
{
- for (int j = m_applicationCertificates.Count - 1; j > i; j--)
+ for (int j = newCertificates.Count - 1; j > i; j--)
{
- if (m_applicationCertificates[i]
- .CertificateType == m_applicationCertificates[j].CertificateType)
+ bool isDuplicate = false;
+
+ // Only check for duplicates if both certificates are actually loaded
+ if (newCertificates[i].Certificate != null && newCertificates[j].Certificate != null)
+ {
+ // Compare by actual certificate thumbprint
+ isDuplicate = newCertificates[i].Certificate.Thumbprint.Equals(
+ newCertificates[j].Certificate.Thumbprint,
+ StringComparison.OrdinalIgnoreCase);
+ }
+ // If certificates aren't loaded yet, compare by explicit thumbprint configuration
+ else if (!string.IsNullOrEmpty(newCertificates[i].Thumbprint) &&
+ !string.IsNullOrEmpty(newCertificates[j].Thumbprint))
+ {
+ isDuplicate = newCertificates[i].Thumbprint.Equals(
+ newCertificates[j].Thumbprint,
+ StringComparison.OrdinalIgnoreCase);
+ }
+
+ if (isDuplicate)
{
- m_applicationCertificates.RemoveAt(j);
+ newCertificates.RemoveAt(j);
}
}
}
+ m_applicationCertificates = newCertificates;
+
SupportedSecurityPolicies = BuildSupportedSecurityPolicies();
}
}
diff --git a/Stack/Opc.Ua.Core/Security/Certificates/CertificateFactory.cs b/Stack/Opc.Ua.Core/Security/Certificates/CertificateFactory.cs
index d8e6609da9..432d70b9d1 100644
--- a/Stack/Opc.Ua.Core/Security/Certificates/CertificateFactory.cs
+++ b/Stack/Opc.Ua.Core/Security/Certificates/CertificateFactory.cs
@@ -287,10 +287,10 @@ public static byte[] CreateSigningRequest(
}
}
- string applicationUri = X509Utils.GetApplicationUriFromCertificate(certificate);
+ var applicationUris = X509Utils.GetApplicationUrisFromCertificate(certificate);
// Subject Alternative Name
- var subjectAltName = new X509SubjectAltNameExtension(applicationUri, domainNames);
+ var subjectAltName = new X509SubjectAltNameExtension(applicationUris, domainNames);
request.CertificateExtensions.Add(new X509Extension(subjectAltName, false));
if (!isECDsaSignature)
{
diff --git a/Stack/Opc.Ua.Core/Security/Certificates/CertificateIdentifier.cs b/Stack/Opc.Ua.Core/Security/Certificates/CertificateIdentifier.cs
index 303d3e49d8..3793c5e820 100644
--- a/Stack/Opc.Ua.Core/Security/Certificates/CertificateIdentifier.cs
+++ b/Stack/Opc.Ua.Core/Security/Certificates/CertificateIdentifier.cs
@@ -623,7 +623,7 @@ public static X509Certificate2 Find(
{
foreach (X509Certificate2 certificate in collection)
{
- if (applicationUri == X509Utils.GetApplicationUriFromCertificate(certificate) &&
+ if (X509Utils.CompareApplicationUriWithCertificate(certificate, applicationUri) &&
ValidateCertificateType(certificate, certificateType) &&
(!needPrivateKey || certificate.HasPrivateKey))
{
diff --git a/Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs b/Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs
index 506f3a44ea..93dde7fc2d 100644
--- a/Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs
+++ b/Stack/Opc.Ua.Core/Security/Certificates/CertificateValidator.cs
@@ -1911,6 +1911,79 @@ public void ValidateDomains(
}
}
+ ///
+ /// Validate application Uri in a server certificate against endpoint used for connection.
+ /// A url mismatch can be accepted by the certificate validation event,
+ /// otherwise an exception is thrown.
+ ///
+ /// The server certificate which contains the application Uri.
+ /// The endpoint used to connect to a server.
+ ///
+ /// if the application Uri can not be found in
+ /// the subject alternate names field in the certificate.
+ ///
+ public void ValidateApplicationUri(X509Certificate2 serverCertificate, ConfiguredEndpoint endpoint)
+ {
+ ServiceResult serviceResult = ValidateServerCertificateApplicationUri(serverCertificate, endpoint);
+
+ if (ServiceResult.IsBad(serviceResult))
+ {
+ bool accept = false;
+ if (m_CertificateValidation != null)
+ {
+ var args = new CertificateValidationEventArgs(serviceResult, serverCertificate);
+ m_CertificateValidation(this, args);
+ accept = args.Accept || args.AcceptAll;
+ }
+
+ // throw if rejected.
+ if (!accept)
+ {
+ // write the invalid certificate to rejected store if specified.
+ m_logger.LogError(
+ "Certificate {Certificate} rejected. Reason={ServiceResult}.",
+ serverCertificate.AsLogSafeString(),
+ Redact.Create(serviceResult));
+ Task.Run(async () => await SaveCertificateAsync(serverCertificate).ConfigureAwait(false));
+
+ throw new ServiceResultException(serviceResult);
+ }
+ }
+ }
+
+ private static ServiceResult ValidateServerCertificateApplicationUri(X509Certificate2 serverCertificate, ConfiguredEndpoint endpoint)
+ {
+ var applicationUri = endpoint?.Description?.Server?.ApplicationUri;
+
+ // check that an ApplicatioUri is specified for the Endpoint
+ if (string.IsNullOrEmpty(applicationUri))
+ {
+ return ServiceResult.Create(
+ StatusCodes.BadCertificateUriInvalid,
+ "Server did not return an ApplicationUri in the EndpointDescription.");
+ }
+
+ // Check if the application URI matches any URI in the certificate
+ // and get the list of certificate URIs in a single call
+ if (!X509Utils.CompareApplicationUriWithCertificate(serverCertificate, applicationUri, out var certificateApplicationUris))
+ {
+ if (certificateApplicationUris.Count == 0)
+ {
+ return ServiceResult.Create(
+ StatusCodes.BadCertificateUriInvalid,
+ "The Server Certificate ({1}) does not contain an applicationUri.",
+ serverCertificate.Subject);
+ }
+
+ return ServiceResult.Create(
+ StatusCodes.BadCertificateUriInvalid,
+ "The Application in the EndpointDescription ({0}) is not in the Server Certificate ({1}).",
+ applicationUri, serverCertificate.Subject);
+ }
+
+ return ServiceResult.Good;
+ }
+
///
/// Returns an error if the chain status elements indicate an error.
///
diff --git a/Stack/Opc.Ua.Core/Security/Certificates/DirectoryCertificateStore.cs b/Stack/Opc.Ua.Core/Security/Certificates/DirectoryCertificateStore.cs
index 39ee21eb61..867acc56a9 100644
--- a/Stack/Opc.Ua.Core/Security/Certificates/DirectoryCertificateStore.cs
+++ b/Stack/Opc.Ua.Core/Security/Certificates/DirectoryCertificateStore.cs
@@ -637,10 +637,7 @@ public async Task LoadPrivateKeyAsync(
}
if (!string.IsNullOrEmpty(applicationUri) &&
- !string.Equals(
- X509Utils.GetApplicationUriFromCertificate(certificate),
- applicationUri,
- StringComparison.OrdinalIgnoreCase))
+ !X509Utils.CompareApplicationUriWithCertificate(certificate, applicationUri))
{
continue;
}
diff --git a/Stack/Opc.Ua.Core/Security/Certificates/X509Utils.cs b/Stack/Opc.Ua.Core/Security/Certificates/X509Utils.cs
index 7b044a5e02..99cb47a8f8 100644
--- a/Stack/Opc.Ua.Core/Security/Certificates/X509Utils.cs
+++ b/Stack/Opc.Ua.Core/Security/Certificates/X509Utils.cs
@@ -153,6 +153,7 @@ public static int GetPublicKeySize(X509Certificate2 certificate)
///
/// The certificate.
/// The application URI.
+ [Obsolete("Use GetApplicationUrisFromCertificate instead. The certificate may contain more than one Uri.")]
public static string GetApplicationUriFromCertificate(X509Certificate2 certificate)
{
// extract the alternate domains from the subject alternate name extension.
@@ -168,6 +169,69 @@ public static string GetApplicationUriFromCertificate(X509Certificate2 certifica
return string.Empty;
}
+ ///
+ /// Extracts the application URIs specified in the certificate.
+ ///
+ /// The certificate.
+ /// The application URIs.
+ public static IReadOnlyList GetApplicationUrisFromCertificate(X509Certificate2 certificate)
+ {
+ // extract the alternate domains from the subject alternate name extension.
+ X509SubjectAltNameExtension alternateName = certificate
+ .FindExtension();
+
+ // get the application uris.
+ if (alternateName != null && alternateName.Uris != null)
+ {
+ return alternateName.Uris;
+ }
+
+ return new List();
+ }
+
+ ///
+ /// Checks if the specified application URI matches any of the URIs in the certificate.
+ ///
+ /// The certificate to check.
+ /// The application URI to match.
+ /// True if the application URI matches any URI in the certificate; otherwise, false.
+ public static bool CompareApplicationUriWithCertificate(X509Certificate2 certificate, string applicationUri)
+ {
+ return CompareApplicationUriWithCertificate(certificate, applicationUri, out _);
+ }
+
+ ///
+ /// Checks if the specified application URI matches any of the URIs in the certificate.
+ /// Returns the list of application URIs found in the certificate.
+ ///
+ /// The certificate to check.
+ /// The application URI to match.
+ /// The list of application URIs found in the certificate.
+ /// True if the application URI matches any URI in the certificate; otherwise, false.
+ public static bool CompareApplicationUriWithCertificate(
+ X509Certificate2 certificate,
+ string applicationUri,
+ out IReadOnlyList certificateApplicationUris)
+ {
+ certificateApplicationUris = GetApplicationUrisFromCertificate(certificate);
+
+ if (string.IsNullOrEmpty(applicationUri))
+ {
+ return false;
+ }
+
+ foreach (var certificateApplicationUri in certificateApplicationUris)
+ {
+ if (!string.IsNullOrEmpty(certificateApplicationUri) &&
+ string.Equals(certificateApplicationUri, applicationUri, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
///
/// Check if certificate has an application urn.
///
diff --git a/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs b/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs
index 31531d2c24..b8115c51b2 100644
--- a/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs
+++ b/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs
@@ -1395,8 +1395,9 @@ protected virtual void OnServerStarting(ApplicationConfiguration configuration)
.GetInstanceCertificate(
configuration.ServerConfiguration.SecurityPolicies[0].SecurityPolicyUri);
- configuration.ApplicationUri = X509Utils.GetApplicationUriFromCertificate(
- instanceCertificate);
+ // it is ok to pick the first here since it is only a fallback value
+ configuration.ApplicationUri = X509Utils.GetApplicationUrisFromCertificate(
+ instanceCertificate).FirstOrDefault();
if (string.IsNullOrEmpty(configuration.ApplicationUri))
{
diff --git a/Tests/Opc.Ua.Configuration.Tests/ApplicationInstanceTests.cs b/Tests/Opc.Ua.Configuration.Tests/ApplicationInstanceTests.cs
index 54521d04c3..21522f073b 100644
--- a/Tests/Opc.Ua.Configuration.Tests/ApplicationInstanceTests.cs
+++ b/Tests/Opc.Ua.Configuration.Tests/ApplicationInstanceTests.cs
@@ -28,11 +28,15 @@
* ======================================================================*/
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
+using Opc.Ua.Security.Certificates;
using Opc.Ua.Tests;
using Assert = NUnit.Framework.Legacy.ClassicAssert;
#if NETCOREAPP2_1_OR_GREATER && !NET_STANDARD_TESTS
@@ -893,6 +897,466 @@ await applicationInstance.CheckApplicationInstanceCertificatesAsync(true)
}
}
+ ///
+ /// Test that multiple certificates with different ApplicationUris throw an exception.
+ /// Even though FindAsync might load certificates by SubjectName without checking ApplicationUri,
+ /// the explicit validation against the configured ApplicationUri ensures all loaded certificates have the same URI.
+ ///
+ [Test]
+ public async Task TestMultipleCertificatesDifferentUrisThrowsExceptionAsync()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+
+ var applicationInstance = new ApplicationInstance(telemetry) { ApplicationName = ApplicationName };
+ Assert.NotNull(applicationInstance);
+
+ // Create two certificates with different ApplicationUris
+ const string uri1 = "urn:localhost:opcfoundation.org:App1";
+ const string uri2 = "urn:localhost:opcfoundation.org:App2";
+
+ X509Certificate2 cert1 = CertificateFactory
+ .CreateCertificate(uri1, ApplicationName, SubjectName, [Utils.GetHostName()])
+ .SetNotBefore(DateTime.Today.AddDays(-1))
+ .SetNotAfter(DateTime.Today.AddYears(1))
+ .CreateForRSA();
+
+ const string subjectName2 = "CN=UA Configuration Test 2, O=OPC Foundation, C=US, S=Arizona";
+ X509Certificate2 cert2 = CertificateFactory
+ .CreateCertificate(uri2, ApplicationName, subjectName2, [Utils.GetHostName()])
+ .SetNotBefore(DateTime.Today.AddDays(-1))
+ .SetNotAfter(DateTime.Today.AddYears(1))
+ .SetRSAKeySize(CertificateFactory.DefaultKeySize)
+ .CreateForRSA();
+
+ // Save certificates to stores
+ string certStorePath = m_pkiRoot + "own";
+ var certStoreIdentifier = new CertificateStoreIdentifier(certStorePath, CertificateStoreType.Directory, false);
+ using (ICertificateStore certStore = certStoreIdentifier.OpenStore(telemetry))
+ {
+ await certStore.AddAsync(cert1).ConfigureAwait(false);
+ await certStore.AddAsync(cert2).ConfigureAwait(false);
+ certStore.Close();
+ }
+
+ var certId1 = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = SubjectName,
+ CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType
+ };
+
+ var certId2 = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = subjectName2,
+ CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType
+ };
+
+ ApplicationConfiguration config = await applicationInstance
+ .Build(uri1, ProductUri)
+ .AsClient()
+ .AddSecurityConfiguration(SubjectName, m_pkiRoot)
+ .CreateAsync()
+ .ConfigureAwait(false);
+ Assert.NotNull(config);
+
+ // Set multiple certificates
+ config.SecurityConfiguration.ApplicationCertificates = new CertificateIdentifierCollection
+ {
+ certId1,
+ certId2
+ };
+
+ // This should throw because all certificates must have the same ApplicationUri
+ ServiceResultException sre = NUnit.Framework.Assert
+ .ThrowsAsync(async () =>
+ await applicationInstance.CheckApplicationInstanceCertificatesAsync(true)
+ .ConfigureAwait(false));
+ Assert.AreEqual(StatusCodes.BadConfigurationError, sre.StatusCode);
+ Assert.That(sre.Message, Does.Contain("certificate") & Does.Contain("invalid"));
+ }
+
+ ///
+ /// Test that multiple certificates with the same ApplicationUri succeed.
+ ///
+ [Test]
+ public async Task TestMultipleCertificatesSameUriSucceedsAsync()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+
+ var applicationInstance = new ApplicationInstance(telemetry) { ApplicationName = ApplicationName };
+ Assert.NotNull(applicationInstance);
+
+ // Create two certificates with the same ApplicationUri
+ X509Certificate2 cert1 = CertificateFactory
+ .CreateCertificate(ApplicationUri, ApplicationName, SubjectName, [Utils.GetHostName()])
+ .SetNotBefore(DateTime.Today.AddDays(-1))
+ .SetNotAfter(DateTime.Today.AddYears(1))
+ .CreateForRSA();
+
+ const string subjectName2 = "CN=UA Configuration Test RSA, O=OPC Foundation, C=US, S=Arizona";
+ X509Certificate2 cert2 = CertificateFactory
+ .CreateCertificate(ApplicationUri, ApplicationName, subjectName2, [Utils.GetHostName()])
+ .SetNotBefore(DateTime.Today.AddDays(-1))
+ .SetNotAfter(DateTime.Today.AddYears(1))
+ .SetRSAKeySize(CertificateFactory.DefaultKeySize)
+ .CreateForRSA();
+
+ // Save certificates to stores
+ string certStorePath = m_pkiRoot + "own";
+ var certStoreIdentifier = new CertificateStoreIdentifier(certStorePath, CertificateStoreType.Directory, false);
+ using (ICertificateStore certStore = certStoreIdentifier.OpenStore(telemetry))
+ {
+ await certStore.AddAsync(cert1).ConfigureAwait(false);
+ await certStore.AddAsync(cert2).ConfigureAwait(false);
+ certStore.Close();
+ }
+
+ var certId1 = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = SubjectName,
+ CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType
+ };
+
+ var certId2 = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = subjectName2,
+ CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType
+ };
+
+ ApplicationConfiguration config = await applicationInstance
+ .Build(ApplicationUri, ProductUri)
+ .AsClient()
+ .AddSecurityConfiguration(SubjectName, m_pkiRoot)
+ .CreateAsync()
+ .ConfigureAwait(false);
+ Assert.NotNull(config);
+
+ // Set multiple certificates with same URI
+ config.SecurityConfiguration.ApplicationCertificates = new CertificateIdentifierCollection
+ {
+ certId1,
+ certId2
+ };
+
+ // This should succeed because all certificates have the same ApplicationUri
+ bool certOK = await applicationInstance
+ .CheckApplicationInstanceCertificatesAsync(true)
+ .ConfigureAwait(false);
+ Assert.True(certOK);
+ }
+
+ ///
+ /// Test that a certificate with multiple SAN URI entries where one matches the ApplicationUri succeeds.
+ /// Tests with different certificate types (RSA, ECC NIST P-256, ECC NIST P-384, ECC Brainpool).
+ ///
+ [Test]
+ [TestCaseSource(nameof(CertificateTypes))]
+ public async Task TestCertificateWithMultipleSanUrisMatchingSucceedsAsync(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.");
+ }
+
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+
+ var applicationInstance = new ApplicationInstance(telemetry) { ApplicationName = ApplicationName };
+ Assert.NotNull(applicationInstance);
+
+ // Create a certificate with multiple URIs in SAN, including the ApplicationUri
+ const string uri1 = "urn:localhost:opcfoundation.org:App1";
+ const string uri2 = ApplicationUri; // This matches
+ const string uri3 = "https://localhost:8080/OpcUaApp";
+
+ X509Certificate2 cert = CreateCertificateWithMultipleUris(
+ [uri1, uri2, uri3],
+ SubjectName,
+ [Utils.GetHostName()],
+ certificateType);
+
+ // Save certificate to store
+ string certStorePath = m_pkiRoot + "own";
+ var certStoreIdentifier = new CertificateStoreIdentifier(certStorePath, CertificateStoreType.Directory, false);
+ using (ICertificateStore certStore = certStoreIdentifier.OpenStore(telemetry))
+ {
+ await certStore.AddAsync(cert).ConfigureAwait(false);
+ certStore.Close();
+ }
+
+ var certId = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = SubjectName,
+ CertificateType = certificateType
+ };
+
+ ApplicationConfiguration config = await applicationInstance
+ .Build(ApplicationUri, ProductUri)
+ .AsClient()
+ .AddSecurityConfiguration(SubjectName, m_pkiRoot)
+ .SetMinimumCertificateKeySize(256)
+ .CreateAsync()
+ .ConfigureAwait(false);
+ Assert.NotNull(config);
+
+ config.SecurityConfiguration.ApplicationCertificates = new CertificateIdentifierCollection { certId };
+
+ // This should succeed because one of the URIs matches
+ bool certOK = await applicationInstance
+ .CheckApplicationInstanceCertificatesAsync(true)
+ .ConfigureAwait(false);
+ Assert.True(certOK);
+
+ // Verify the certificate has multiple URIs
+ // Load the certificate to check its URIs
+ X509Certificate2 loadedCert = await certId.FindAsync(false, null, telemetry).ConfigureAwait(false);
+ IReadOnlyList uris = X509Utils.GetApplicationUrisFromCertificate(loadedCert);
+ Assert.AreEqual(3, uris.Count);
+ Assert.Contains(uri1, uris.ToList());
+ Assert.Contains(uri2, uris.ToList());
+ Assert.Contains(uri3, uris.ToList());
+ }
+
+ ///
+ /// Test that a certificate with multiple SAN URI entries where none matches the ApplicationUri fails.
+ /// Tests with different certificate types (RSA, ECC NIST P-256, ECC NIST P-384, ECC Brainpool).
+ ///
+ [Test]
+ [TestCaseSource(nameof(CertificateTypes))]
+ public async Task TestCertificateWithMultipleSanUrisNotMatchingThrowsAsync(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.");
+ }
+
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+
+ var applicationInstance = new ApplicationInstance(telemetry) { ApplicationName = ApplicationName };
+ Assert.NotNull(applicationInstance);
+
+ // Create a certificate with multiple URIs in SAN, but none matching ApplicationUri
+ const string uri1 = "urn:localhost:opcfoundation.org:App1";
+ const string uri2 = "urn:localhost:opcfoundation.org:App2";
+ const string uri3 = "https://localhost:8080/OpcUaApp";
+
+ X509Certificate2 cert = CreateCertificateWithMultipleUris(
+ [uri1, uri2, uri3],
+ SubjectName,
+ [Utils.GetHostName()],
+ certificateType);
+
+ // Save certificate to store
+ string certStorePath = m_pkiRoot + "own";
+ var certStoreIdentifier = new CertificateStoreIdentifier(certStorePath, CertificateStoreType.Directory, false);
+ using (ICertificateStore certStore = certStoreIdentifier.OpenStore(telemetry))
+ {
+ await certStore.AddAsync(cert).ConfigureAwait(false);
+ certStore.Close();
+ }
+
+ var certId = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = SubjectName,
+ CertificateType = certificateType
+ };
+
+ ApplicationConfiguration config = await applicationInstance
+ .Build(ApplicationUri, ProductUri)
+ .AsClient()
+ .AddSecurityConfiguration(SubjectName, m_pkiRoot)
+ .SetMinimumCertificateKeySize(256)
+ .CreateAsync()
+ .ConfigureAwait(false);
+ Assert.NotNull(config);
+
+ config.SecurityConfiguration.ApplicationCertificates = new CertificateIdentifierCollection { certId };
+
+ // This should fail because none of the URIs match
+ ServiceResultException sre = NUnit.Framework.Assert
+ .ThrowsAsync(async () =>
+ await applicationInstance.CheckApplicationInstanceCertificatesAsync(true)
+ .ConfigureAwait(false));
+ Assert.AreEqual(StatusCodes.BadConfigurationError, sre.StatusCode);
+ }
+
+ ///
+ /// Test with multiple certificates, each with multiple SAN URIs, where all match the ApplicationUri.
+ /// Tests with different certificate types (RSA, ECC NIST P-256, ECC NIST P-384, ECC Brainpool).
+ ///
+ [Test]
+ [TestCaseSource(nameof(CertificateTypes))]
+ public async Task TestMultipleCertificatesWithMultipleSanUrisAllMatchingSucceedsAsync(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.");
+ }
+
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+
+ var applicationInstance = new ApplicationInstance(telemetry) { ApplicationName = ApplicationName };
+ Assert.NotNull(applicationInstance);
+
+ // Create first certificate with multiple URIs including ApplicationUri
+ X509Certificate2 cert1 = CreateCertificateWithMultipleUris(
+ [ApplicationUri, "https://localhost:8080/Test1", "opc.tcp://localhost:4840/Test1"],
+ SubjectName,
+ [Utils.GetHostName()],
+ certificateType);
+
+ const string subjectName2 = "CN=UA Configuration Test 2, O=OPC Foundation, C=US, S=Arizona";
+ // Create second certificate with multiple URIs including ApplicationUri
+ X509Certificate2 cert2 = CreateCertificateWithMultipleUris(
+ ["urn:localhost:opcfoundation.org:OtherApp", ApplicationUri, "https://localhost:9443/Test2"],
+ subjectName2,
+ [Utils.GetHostName()],
+ certificateType);
+
+ // Save certificates to store
+ string certStorePath = m_pkiRoot + "own";
+ var certStoreIdentifier = new CertificateStoreIdentifier(certStorePath, CertificateStoreType.Directory, false);
+ using (ICertificateStore certStore = certStoreIdentifier.OpenStore(telemetry))
+ {
+ await certStore.AddAsync(cert1).ConfigureAwait(false);
+ await certStore.AddAsync(cert2).ConfigureAwait(false);
+ certStore.Close();
+ }
+
+ var certId1 = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = SubjectName,
+ CertificateType = certificateType
+ };
+
+ var certId2 = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = subjectName2,
+ CertificateType = certificateType
+ };
+
+ ApplicationConfiguration config = await applicationInstance
+ .Build(ApplicationUri, ProductUri)
+ .AsClient()
+ .AddSecurityConfiguration(SubjectName, m_pkiRoot)
+ .SetMinimumCertificateKeySize(256)
+ .CreateAsync()
+ .ConfigureAwait(false);
+ Assert.NotNull(config);
+
+ config.SecurityConfiguration.ApplicationCertificates = new CertificateIdentifierCollection
+ {
+ certId1,
+ certId2
+ };
+
+ // This should succeed because both certificates contain ApplicationUri in their SAN
+ bool certOK = await applicationInstance
+ .CheckApplicationInstanceCertificatesAsync(true)
+ .ConfigureAwait(false);
+ Assert.True(certOK);
+ }
+
+ ///
+ /// Test with multiple certificates where only one has the matching ApplicationUri in its SAN URIs.
+ /// Tests with different certificate types (RSA, ECC NIST P-256, ECC NIST P-384, ECC Brainpool).
+ ///
+ [Test]
+ [TestCaseSource(nameof(CertificateTypes))]
+ public async Task TestMultipleCertificatesWithOnlyOneMatchingSanUriThrowsAsync(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.");
+ }
+
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+
+ var applicationInstance = new ApplicationInstance(telemetry) { ApplicationName = ApplicationName };
+ Assert.NotNull(applicationInstance);
+
+ // Create first certificate with ApplicationUri
+ X509Certificate2 cert1 = CreateCertificateWithMultipleUris(
+ [ApplicationUri, "https://localhost:8080/Test1"],
+ SubjectName,
+ [Utils.GetHostName()],
+ certificateType);
+
+ const string subjectName2 = "CN=UA Configuration Test 2, O=OPC Foundation, C=US, S=Arizona";
+ // Create second certificate WITHOUT ApplicationUri
+ X509Certificate2 cert2 = CreateCertificateWithMultipleUris(
+ ["urn:localhost:opcfoundation.org:OtherApp", "https://localhost:9443/Test2"],
+ subjectName2,
+ [Utils.GetHostName()],
+ certificateType);
+
+ // Save certificates to store
+ string certStorePath = m_pkiRoot + "own";
+ var certStoreIdentifier = new CertificateStoreIdentifier(certStorePath, CertificateStoreType.Directory, false);
+ using (ICertificateStore certStore = certStoreIdentifier.OpenStore(telemetry))
+ {
+ await certStore.AddAsync(cert1).ConfigureAwait(false);
+ await certStore.AddAsync(cert2).ConfigureAwait(false);
+ certStore.Close();
+ }
+
+ var certId1 = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = SubjectName,
+ CertificateType = certificateType
+ };
+
+ var certId2 = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = subjectName2,
+ CertificateType = certificateType
+ };
+
+ ApplicationConfiguration config = await applicationInstance
+ .Build(ApplicationUri, ProductUri)
+ .AsClient()
+ .AddSecurityConfiguration(SubjectName, m_pkiRoot)
+ .SetMinimumCertificateKeySize(256)
+ .CreateAsync()
+ .ConfigureAwait(false);
+ Assert.NotNull(config);
+
+ config.SecurityConfiguration.ApplicationCertificates = new CertificateIdentifierCollection
+ {
+ certId1,
+ certId2
+ };
+
+ // This should fail because cert2 doesn't contain ApplicationUri
+ ServiceResultException sre = NUnit.Framework.Assert
+ .ThrowsAsync(async () =>
+ await applicationInstance.CheckApplicationInstanceCertificatesAsync(true)
+ .ConfigureAwait(false));
+ Assert.AreEqual(StatusCodes.BadConfigurationError, sre.StatusCode);
+ }
+
private static X509Certificate2 CreateInvalidCert(InvalidCertType certType)
{
// reasonable defaults
@@ -989,6 +1453,81 @@ private static X509Certificate2Collection CreateInvalidCertChain(InvalidCertType
return [appCert, CertificateFactory.Create(rootCA.RawData)];
}
+ ///
+ /// Provides a collection of certificate types for parameterized tests.
+ ///
+ /// An enumerable collection of NodeIds representing different certificate types.
+ ///
+ /// This method is used as a TestCaseSource to provide different certificate types (RSA, ECC) for testing.
+ /// Individual tests check platform support using Utils.IsSupportedCertificateType().
+ ///
+ private static IEnumerable CertificateTypes()
+ {
+ yield return ObjectTypeIds.RsaSha256ApplicationCertificateType;
+ yield return ObjectTypeIds.EccNistP256ApplicationCertificateType;
+ yield return ObjectTypeIds.EccNistP384ApplicationCertificateType;
+ yield return ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType;
+ yield return ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType;
+ }
+
+ ///
+ /// Helper method to create a certificate with multiple URI entries in the SAN.
+ ///
+ /// The list of application URIs to include in the SAN
+ /// The subject name for the certificate
+ /// The domain names for the certificate
+ /// A certificate with multiple URIs in the SAN extension
+ private static X509Certificate2 CreateCertificateWithMultipleUris(
+ IList applicationUris,
+ string subjectName,
+ IList domainNames,
+ NodeId certificateType = null)
+ {
+ DateTime notBefore = DateTime.Today.AddDays(-1);
+ DateTime notAfter = DateTime.Today.AddYears(1);
+
+ // Default to RSA if not specified
+ certificateType ??= ObjectTypeIds.RsaSha256ApplicationCertificateType;
+
+ // Create the SAN extension with multiple URIs
+ var subjectAltName = new X509SubjectAltNameExtension(applicationUris, domainNames);
+
+ // Build the certificate with the custom SAN extension
+ var 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();
+ }
+ }
+
private string m_pkiRoot;
private static readonly string[] s_alternateBaseAddresses
diff --git a/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateFactoryTest.cs b/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateFactoryTest.cs
index c5f9e32abd..5a9ede5db6 100644
--- a/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateFactoryTest.cs
+++ b/Tests/Opc.Ua.Core.Tests/Security/Certificates/CertificateFactoryTest.cs
@@ -460,8 +460,8 @@ public static void VerifyApplicationCert(
Assert.True(domainNames.Contains(domainName, StringComparer.OrdinalIgnoreCase));
}
Assert.True(subjectAlternateName.Uris.Count == 1);
- string applicationUri = X509Utils.GetApplicationUriFromCertificate(cert);
- TestContext.Out.WriteLine("ApplicationUri: ");
+ var applicationUri = X509Utils.GetApplicationUrisFromCertificate(cert).FirstOrDefault();
+ TestContext.Out.WriteLine("ApplicationUris: ");
TestContext.Out.WriteLine(applicationUri);
Assert.AreEqual(testApp.ApplicationUri, applicationUri);
}
diff --git a/Tests/Opc.Ua.Core.Tests/Types/Encoders/EncoderTests.cs b/Tests/Opc.Ua.Core.Tests/Types/Encoders/EncoderTests.cs
index 34e590733a..83d16b5fb4 100644
--- a/Tests/Opc.Ua.Core.Tests/Types/Encoders/EncoderTests.cs
+++ b/Tests/Opc.Ua.Core.Tests/Types/Encoders/EncoderTests.cs
@@ -942,7 +942,7 @@ public void EncodeMatrixInArray(
///
/// Verify that decoding of a Matrix DataValue which has invalid array dimensions.
- ///
[Theory]
[Category("Matrix")]
public void MatrixOverflow(
@@ -1038,7 +1038,7 @@ public void MatrixOverflow(
///
/// Verify that decoding of a Matrix DataValue which has statical provided invalid array dimensions.
- ///
[Theory]
[Category("Matrix")]
public void MatrixOverflowStaticDimensions(
diff --git a/Tests/Opc.Ua.Gds.Tests/X509TestUtils.cs b/Tests/Opc.Ua.Gds.Tests/X509TestUtils.cs
index ec85d82a0d..e122b54e19 100644
--- a/Tests/Opc.Ua.Gds.Tests/X509TestUtils.cs
+++ b/Tests/Opc.Ua.Gds.Tests/X509TestUtils.cs
@@ -198,7 +198,7 @@ public static void VerifySignedApplicationCert(
Assert.True(domainNames.Contains(domainName, StringComparer.OrdinalIgnoreCase));
}
Assert.True(subjectAlternateName.Uris.Count == 1);
- string applicationUri = X509Utils.GetApplicationUriFromCertificate(signedCert);
+ var applicationUri = X509Utils.GetApplicationUrisFromCertificate(signedCert).FirstOrDefault();
Assert.True(testApp.ApplicationRecord.ApplicationUri == applicationUri);
}
}
diff --git a/Tests/Opc.Ua.Server.Tests/CreateSessionApplicationUriValidationTests.cs b/Tests/Opc.Ua.Server.Tests/CreateSessionApplicationUriValidationTests.cs
new file mode 100644
index 0000000000..69aee77895
--- /dev/null
+++ b/Tests/Opc.Ua.Server.Tests/CreateSessionApplicationUriValidationTests.cs
@@ -0,0 +1,429 @@
+/* ========================================================================
+ * 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/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading;
+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.Tests;
+using Assert = NUnit.Framework.Legacy.ClassicAssert;
+
+namespace Opc.Ua.Server.Tests
+{
+ ///
+ /// 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.
+ ///
+ [TestFixture]
+ [Category("Server")]
+ [SetCulture("en-us")]
+ [SetUICulture("en-us")]
+ [Parallelizable]
+ public class CreateSessionApplicationUriValidationTests
+ {
+ private const string ClientApplicationUri = "urn:localhost:opcfoundation.org:TestClient";
+ private const string ClientSubjectName = "CN=TestClient, O=OPC Foundation";
+ private ServerFixture m_serverFixture;
+ private string m_pkiRoot;
+
+ ///
+ /// 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.
+ ///
+ private static readonly NodeId[] CertificateTypes = new[]
+ {
+ 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
+ {
+ 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
+ }
+ }
+
+ ///
+ /// Test that CreateSession succeeds when client certificate has matching ApplicationUri.
+ ///
+ [Test]
+ [TestCaseSource(nameof(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
+ X509Certificate2 clientCert = CreateCertificateWithMultipleUris(
+ [ClientApplicationUri],
+ ClientSubjectName,
+ [Utils.GetHostName()],
+ certificateType);
+
+ // Attempt to create session - should succeed
+ Client.ISession session = await CreateSessionWithCustomCertificateAsync(clientCert, ClientApplicationUri).ConfigureAwait(false);
+ Assert.NotNull(session);
+ Assert.IsTrue(session.Connected, "Session should be connected");
+
+ // Verify session is functional by reading server state
+ DataValue result = await session.ReadValueAsync(VariableIds.Server_ServerStatus_State).ConfigureAwait(false);
+ Assert.NotNull(result, "Should be able to read server state");
+ Assert.AreEqual(StatusCodes.Good, result.StatusCode, "Read operation should succeed");
+
+ await session.CloseAsync(5_000, true).ConfigureAwait(false);
+ session.Dispose();
+ }
+
+ ///
+ /// Test that CreateSession throws BadCertificateUriInvalid when client certificate ApplicationUri doesn't match.
+ ///
+ [Test]
+ [TestCaseSource(nameof(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";
+ X509Certificate2 clientCert = CreateCertificateWithMultipleUris(
+ [certUri],
+ ClientSubjectName,
+ [Utils.GetHostName()],
+ certificateType);
+
+ // Attempt to create session - should throw BadCertificateUriInvalid
+ var ex = Assert.ThrowsAsync(async () =>
+ await CreateSessionWithCustomCertificateAsync(clientCert, ClientApplicationUri).ConfigureAwait(false));
+ Assert.AreEqual((StatusCode)StatusCodes.BadCertificateUriInvalid, (StatusCode)ex.StatusCode);
+ }
+
+ ///
+ /// Test that CreateSession succeeds when client certificate has multiple URIs and one matches.
+ ///
+ [Test]
+ [TestCaseSource(nameof(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 = ClientApplicationUri; // This matches
+ const string uri3 = "https://localhost:8080/OpcUaApp";
+
+ X509Certificate2 clientCert = CreateCertificateWithMultipleUris(
+ [uri1, uri2, uri3],
+ ClientSubjectName,
+ [Utils.GetHostName()],
+ certificateType);
+
+ // Verify certificate has multiple URIs
+ IReadOnlyList uris = X509Utils.GetApplicationUrisFromCertificate(clientCert);
+ Assert.AreEqual(3, uris.Count);
+ 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, ClientApplicationUri).ConfigureAwait(false);
+ Assert.NotNull(session);
+ Assert.IsTrue(session.Connected, "Session should be connected");
+
+ // Verify session is functional by reading server state
+ DataValue result = await session.ReadValueAsync(VariableIds.Server_ServerStatus_State).ConfigureAwait(false);
+ Assert.NotNull(result, "Should be able to read server state");
+ Assert.AreEqual(StatusCodes.Good, result.StatusCode, "Read operation should succeed");
+
+ await session.CloseAsync(5_000, true).ConfigureAwait(false);
+ session.Dispose();
+ }
+
+ ///
+ /// Test that CreateSession throws BadCertificateUriInvalid when client certificate has multiple URIs but none match.
+ ///
+ [Test]
+ [TestCaseSource(nameof(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";
+
+ X509Certificate2 clientCert = CreateCertificateWithMultipleUris(
+ [uri1, uri2, uri3],
+ ClientSubjectName,
+ [Utils.GetHostName()],
+ certificateType);
+
+ // Verify certificate has multiple URIs
+ IReadOnlyList uris = X509Utils.GetApplicationUrisFromCertificate(clientCert);
+ Assert.AreEqual(3, uris.Count);
+ Assert.Contains(uri1, uris.ToList());
+ Assert.Contains(uri2, uris.ToList());
+ Assert.Contains(uri3, uris.ToList());
+
+ // Attempt to create session - should throw BadCertificateUriInvalid
+ var ex = Assert.ThrowsAsync(async () =>
+ await CreateSessionWithCustomCertificateAsync(clientCert, ClientApplicationUri).ConfigureAwait(false));
+ Assert.AreEqual((StatusCode)StatusCodes.BadCertificateUriInvalid, (StatusCode)ex.StatusCode);
+ }
+
+ #region Helper Methods
+
+ ///
+ /// Helper method to create a session with a custom client certificate.
+ ///
+ private async Task CreateSessionWithCustomCertificateAsync(
+ X509Certificate2 clientCertificate,
+ string clientApplicationUri)
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ var logger = telemetry.CreateLogger();
+
+ // 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
+ // Setting the Certificate property will automatically set the CertificateType
+ var certIdentifier = new CertificateIdentifier
+ {
+ StoreType = CertificateStoreType.Directory,
+ StorePath = certStorePath,
+ SubjectName = clientCertificate.SubjectName.Name,
+ Thumbprint = clientCertificate.Thumbprint,
+ Certificate = clientCertificate
+ };
+
+ // Create client application configuration
+ var clientApp = new ApplicationInstance(telemetry)
+ {
+ ApplicationName = "TestClient",
+ ApplicationType = ApplicationType.Client
+ };
+
+ 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
+ var endpoint = m_serverFixture.Server.GetEndpoints()
+ .FirstOrDefault(e => e.SecurityMode == MessageSecurityMode.SignAndEncrypt &&
+ e.SecurityPolicyUri == SecurityPolicies.Basic256Sha256);
+
+ Assert.NotNull(endpoint, "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 = 5;
+ const int delayMs = 1000;
+ for (int attempt = 0; ; attempt++)
+ {
+ try
+ {
+ return await sessionFactory.CreateAsync(
+ clientConfig,
+ configuredEndpoint,
+ false, // updateBeforeConnect
+ false, // checkDomain
+ "TestSession",
+ 60000, // sessionTimeout
+ null, // userIdentity
+ null) // preferredLocales
+ .ConfigureAwait(false);
+ }
+ catch (ServiceResultException e) when ((e.StatusCode is
+ StatusCodes.BadServerHalted or
+ StatusCodes.BadSecureChannelClosed or
+ StatusCodes.BadNoCommunication or
+ 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.StatusCode);
+ await Task.Delay(delayMs).ConfigureAwait(false);
+ }
+ }
+ }
+ finally
+ {
+ // Clean up temp PKI
+ try
+ {
+ if (Directory.Exists(clientPkiRoot))
+ {
+ Directory.Delete(clientPkiRoot, true);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+ }
+
+ ///
+ /// Creates a certificate with multiple application URIs in the SAN extension.
+ ///
+ private static X509Certificate2 CreateCertificateWithMultipleUris(
+ IList applicationUris,
+ string subjectName,
+ IList domainNames,
+ NodeId certificateType = null)
+ {
+ DateTime notBefore = DateTime.Today.AddDays(-1);
+ DateTime notAfter = DateTime.Today.AddYears(1);
+
+ // Default to RSA if not specified
+ certificateType ??= ObjectTypeIds.RsaSha256ApplicationCertificateType;
+
+ // Create the SAN extension with multiple URIs
+ var subjectAltName = new X509SubjectAltNameExtension(applicationUris, domainNames);
+
+ // Build the certificate with the custom SAN extension
+ var 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();
+ }
+ }
+
+ #endregion Helper Methods
+ }
+}
diff --git a/Tests/Opc.Ua.Server.Tests/Opc.Ua.Server.Tests.csproj b/Tests/Opc.Ua.Server.Tests/Opc.Ua.Server.Tests.csproj
index 5c77ac7ab5..2154de342e 100644
--- a/Tests/Opc.Ua.Server.Tests/Opc.Ua.Server.Tests.csproj
+++ b/Tests/Opc.Ua.Server.Tests/Opc.Ua.Server.Tests.csproj
@@ -32,6 +32,7 @@
+