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 @@ +