From a87a1cd83cee61e8f2d196c860c34b6df3260aac Mon Sep 17 00:00:00 2001 From: Roman Ettlinger Date: Wed, 11 Feb 2026 08:46:24 +0100 Subject: [PATCH 1/3] Allow Mixed User Token Endpoint SecurityPolicies RSA/ ECC --- Libraries/Opc.Ua.Client/Session/Session.cs | 22 +++- .../Opc.Ua.Server/Server/IServerInternal.cs | 6 + .../Server/ServerInternalData.cs | 7 +- Libraries/Opc.Ua.Server/Session/Session.cs | 31 ++--- .../Configuration/EndpointDescription.cs | 105 +++++++++------ Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs | 10 ++ .../MixedSecurityPolicyTests.cs | 120 ++++++++++++++++++ 7 files changed, 240 insertions(+), 61 deletions(-) create mode 100644 Tests/Opc.Ua.Client.Tests/MixedSecurityPolicyTests.cs diff --git a/Libraries/Opc.Ua.Client/Session/Session.cs b/Libraries/Opc.Ua.Client/Session/Session.cs index a3a4f4bb2..280713d13 100644 --- a/Libraries/Opc.Ua.Client/Session/Session.cs +++ b/Libraries/Opc.Ua.Client/Session/Session.cs @@ -1288,7 +1288,7 @@ await m_configuration identityToken.Encrypt( serverCertificate, serverNonce, - m_userTokenSecurityPolicyUri, + m_endpoint.Description.SecurityPolicyUri, MessageContext, m_eccServerEphemeralKey, m_instanceCertificate, @@ -1440,7 +1440,8 @@ public async Task UpdateSessionAsync( m_endpoint.Description.FindUserTokenPolicy( identity.TokenType, identity.IssuedTokenType, - securityPolicyUri) + securityPolicyUri, + [.. m_configuration.SecurityConfiguration.SupportedSecurityPolicies]) ?? throw ServiceResultException.Create( StatusCodes.BadIdentityTokenRejected, "Endpoint does not support the user identity type provided."); @@ -2321,7 +2322,8 @@ public async Task ReconnectAsync( UserTokenPolicy identityPolicy = endpoint.FindUserTokenPolicy( m_identity.TokenType, m_identity.IssuedTokenType, - endpoint.SecurityPolicyUri); + endpoint.SecurityPolicyUri, + [.. m_configuration.SecurityConfiguration.SupportedSecurityPolicies]); if (identityPolicy == null) { @@ -3779,7 +3781,7 @@ private void OpenValidateIdentity( // check that the user identity is supported by the endpoint. identityPolicy = m_endpoint.Description - .FindUserTokenPolicy(identityToken.PolicyId, securityPolicyUri); + .FindUserTokenPolicy(identityToken.PolicyId); if (identityPolicy == null) { @@ -3787,7 +3789,8 @@ private void OpenValidateIdentity( identityPolicy = m_endpoint.Description.FindUserTokenPolicy( identity.TokenType, identity.IssuedTokenType, - securityPolicyUri); + securityPolicyUri, + [.. m_configuration.SecurityConfiguration.SupportedSecurityPolicies]); if (identityPolicy == null) { @@ -4767,10 +4770,17 @@ private RequestHeader CreateRequestHeaderPerUserTokenPolicy( string? endpointSecurityPolicyUri) { var requestHeader = new RequestHeader(); + + if (!EccUtils.IsEccPolicy(endpointSecurityPolicyUri)) + { + // No need to add additional parameters if not using an ECC policy + return requestHeader; + } + string? userTokenSecurityPolicyUri = identityTokenSecurityPolicyUri; if (string.IsNullOrEmpty(userTokenSecurityPolicyUri)) { - userTokenSecurityPolicyUri = m_endpoint.Description.SecurityPolicyUri; + userTokenSecurityPolicyUri = endpointSecurityPolicyUri; } m_userTokenSecurityPolicyUri = userTokenSecurityPolicyUri; diff --git a/Libraries/Opc.Ua.Server/Server/IServerInternal.cs b/Libraries/Opc.Ua.Server/Server/IServerInternal.cs index bbc42ec23..b3468aad9 100644 --- a/Libraries/Opc.Ua.Server/Server/IServerInternal.cs +++ b/Libraries/Opc.Ua.Server/Server/IServerInternal.cs @@ -31,6 +31,7 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Opc.Ua.Security.Certificates; namespace Opc.Ua.Server { @@ -172,6 +173,11 @@ public interface IServerInternal : IAuditEventServer, IDisposable /// ITelemetryContext Telemetry { get; } + /// + /// Provides access to the certificate types supported by the server. + /// + CertificateTypesProvider InstanceCertificateProvider { get; } + /// /// Whether the server is currently running. /// diff --git a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs index 1f7ed1199..a403484dd 100644 --- a/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs +++ b/Libraries/Opc.Ua.Server/Server/ServerInternalData.cs @@ -78,7 +78,7 @@ public ServerInternalData( m_serverDescription = serverDescription; m_configuration = configuration; MessageContext = messageContext; - + InstanceCertificateProvider = instanceCertificateProvider; m_endpointAddresses = []; foreach (string baseAddresses in m_configuration.ServerConfiguration.BaseAddresses) @@ -246,6 +246,11 @@ public void SetModellingRulesManager(ModellingRulesManager modellingRulesManager /// The message context. public IServiceMessageContext MessageContext { get; } + /// + /// Provides access to the certificate types supported by the server. + /// + public CertificateTypesProvider InstanceCertificateProvider { get; } + /// /// The default system context for the server. /// diff --git a/Libraries/Opc.Ua.Server/Session/Session.cs b/Libraries/Opc.Ua.Server/Session/Session.cs index 2ec8fe171..20e7d75b3 100644 --- a/Libraries/Opc.Ua.Server/Session/Session.cs +++ b/Libraries/Opc.Ua.Server/Session/Session.cs @@ -32,6 +32,7 @@ using System.Security.Cryptography.X509Certificates; using System.Threading; using Microsoft.Extensions.Logging; +using Opc.Ua.Security.Certificates; namespace Opc.Ua.Server { @@ -90,6 +91,7 @@ public Session( m_serverNonce = serverNonce; m_sessionName = sessionName; m_serverCertificate = serverCertificate; + m_certificateTypesProvider = server.InstanceCertificateProvider; ClientCertificate = clientCertificate; m_clientIssuerCertificates = clientCertificateChain; @@ -314,7 +316,7 @@ public virtual EphemeralKeyType GetNewEccKey() key.Signature = EccUtils.Sign( new ArraySegment(key.PublicKey), - m_serverCertificate, + m_certificateTypesProvider.GetInstanceCertificate(m_eccUserTokenSecurityPolicyUri), m_eccUserTokenSecurityPolicyUri); return key; @@ -883,8 +885,7 @@ private UserIdentityToken ValidateUserIdentityToken( } policy = EndpointDescription.FindUserTokenPolicy( - newToken.PolicyId, - EndpointDescription.SecurityPolicyUri); + newToken.PolicyId); if (policy == null) { throw ServiceResultException.Create( @@ -951,8 +952,7 @@ private UserIdentityToken ValidateUserIdentityToken( // find the user token policy. policy = EndpointDescription.FindUserTokenPolicy( - token.PolicyId, - EndpointDescription.SecurityPolicyUri); + token.PolicyId); if (policy == null) { @@ -977,27 +977,21 @@ private UserIdentityToken ValidateUserIdentityToken( if (ServerBase.RequireEncryption(EndpointDescription)) { + X509Certificate2 certificate = m_certificateTypesProvider.GetInstanceCertificate(EndpointDescription.SecurityPolicyUri); // decrypt the token. - if (m_serverCertificate == null) + if (certificate == null) { - m_serverCertificate = X509CertificateLoader.LoadCertificate( - EndpointDescription.ServerCertificate); - - // check for valid certificate. - if (m_serverCertificate == null) - { - throw ServiceResultException.Create( - StatusCodes.BadConfigurationError, - "ApplicationCertificate cannot be found."); - } + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "ApplicationCertificate cannot be found."); } try { token.Decrypt( - m_serverCertificate, + certificate, m_serverNonce, - securityPolicyUri, + EndpointDescription.SecurityPolicyUri, m_server.MessageContext, m_eccUserTokenNonce, ClientCertificate, @@ -1257,6 +1251,7 @@ private void UpdateDiagnosticCounters( private readonly IServerInternal m_server; private readonly string m_sessionName; private X509Certificate2 m_serverCertificate; + private CertificateTypesProvider m_certificateTypesProvider; private Nonce m_serverNonce; private string m_eccUserTokenSecurityPolicyUri; private Nonce m_eccUserTokenNonce; diff --git a/Stack/Opc.Ua.Core/Stack/Configuration/EndpointDescription.cs b/Stack/Opc.Ua.Core/Stack/Configuration/EndpointDescription.cs index af9ecdd8c..6768e60af 100644 --- a/Stack/Opc.Ua.Core/Stack/Configuration/EndpointDescription.cs +++ b/Stack/Opc.Ua.Core/Stack/Configuration/EndpointDescription.cs @@ -90,44 +90,76 @@ public BinaryEncodingSupport EncodingSupport /// /// Finds the user token policy with the specified id and securtyPolicyUri /// + [Obsolete("Use FindUserTokenPolicy without tokenSecurityPolicyUri")] public UserTokenPolicy FindUserTokenPolicy(string policyId, string tokenSecurityPolicyUri) { - UserTokenPolicy sameEncryptionAlgorithm = null; - UserTokenPolicy unspecifiedSecPolicy = null; + return FindUserTokenPolicy(policyId); + } + + /// + /// Finds the user token policy with the specified id and securtyPolicyUri + /// + public UserTokenPolicy FindUserTokenPolicy(string policyId) + { // The specified security policies take precedence foreach (UserTokenPolicy policy in m_userIdentityTokens) { if (policy.PolicyId == policyId) { - if (policy.SecurityPolicyUri == tokenSecurityPolicyUri) - { - return policy; - } - else if (( - policy.SecurityPolicyUri != null && - tokenSecurityPolicyUri != null && - EccUtils.IsEccPolicy(policy.SecurityPolicyUri) && - EccUtils.IsEccPolicy(tokenSecurityPolicyUri) - ) || - ( - !EccUtils.IsEccPolicy(policy.SecurityPolicyUri) && - !EccUtils.IsEccPolicy(tokenSecurityPolicyUri))) - { - sameEncryptionAlgorithm ??= policy; - } - else if (policy.SecurityPolicyUri == null) - { - unspecifiedSecPolicy = policy; - } + return policy; } } - // The first token with the same encryption algorithm (RSA/ECC) follows - if (sameEncryptionAlgorithm != null) + + return null; + } + + /// + /// Finds a token policy that matches the user identity specified. + /// + public UserTokenPolicy FindUserTokenPolicy( + UserTokenType tokenType, + XmlQualifiedName issuedTokenType, + string preferredSecurityPolicyUri, + string[] fallbackSecurityPolicyUris) + { + // Use the namespace uri for the issued token type. + string issuedTokenTypeDef = issuedTokenType?.Namespace; + + // Iterate twice: first for exact matches, then for relaxed matches. + foreach (bool exactMatch in new[] { true, false }) { - return sameEncryptionAlgorithm; + // Check preferred policy. + UserTokenPolicy match = FindUserTokenPolicy( + tokenType, + issuedTokenTypeDef, + preferredSecurityPolicyUri, + exactMatch); + + if (match != null) + { + return match; + } + + // Check fallback policies. + if (fallbackSecurityPolicyUris != null) + { + foreach (string policy in fallbackSecurityPolicyUris) + { + match = FindUserTokenPolicy( + tokenType, + issuedTokenTypeDef, + policy, + exactMatch); + + if (match != null) + { + return match; + } + } + } } - // The first token with unspecified security policy follows / no policy - return unspecifiedSecPolicy; + + return null; } /// @@ -136,17 +168,18 @@ public UserTokenPolicy FindUserTokenPolicy(string policyId, string tokenSecurity public UserTokenPolicy FindUserTokenPolicy( UserTokenType tokenType, XmlQualifiedName issuedTokenType, - string tokenSecurityPolicyUri) + string tokenSecurityPolicyUri, + bool matchSecurityPolicyUriExactly = false) { if (issuedTokenType == null) { - return FindUserTokenPolicy(tokenType, (string)null, tokenSecurityPolicyUri); + return FindUserTokenPolicy(tokenType, (string)null, tokenSecurityPolicyUri, matchSecurityPolicyUriExactly); } return FindUserTokenPolicy( tokenType, issuedTokenType.Namespace, - tokenSecurityPolicyUri); + tokenSecurityPolicyUri, matchSecurityPolicyUriExactly); } /// @@ -155,7 +188,8 @@ public UserTokenPolicy FindUserTokenPolicy( public UserTokenPolicy FindUserTokenPolicy( UserTokenType tokenType, string issuedTokenType, - string tokenSecurityPolicyUri) + string tokenSecurityPolicyUri, + bool matchSecurityPolicyUriExactly = false) { // construct issuer type. string issuedTokenTypeText = issuedTokenType; @@ -174,6 +208,7 @@ public UserTokenPolicy FindUserTokenPolicy( return policy; } else if (( + !matchSecurityPolicyUriExactly && policy.SecurityPolicyUri != null && tokenSecurityPolicyUri != null && EccUtils.IsEccPolicy(policy.SecurityPolicyUri) && @@ -185,12 +220,10 @@ public UserTokenPolicy FindUserTokenPolicy( { sameEncryptionAlgorithm ??= policy; } - else if (policy.SecurityPolicyUri == null) + else if ( + (!matchSecurityPolicyUriExactly || tokenSecurityPolicyUri == null) && policy.SecurityPolicyUri == null) { - if (sameEncryptionAlgorithm == null) - { - unspecifiedSecPolicy = policy; - } + unspecifiedSecPolicy = policy; } } } diff --git a/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs b/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs index 13a0b8434..f7c74a65b 100644 --- a/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs +++ b/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs @@ -885,6 +885,16 @@ public virtual UserTokenPolicyCollection GetUserTokenPolicies( foreach (UserTokenPolicy policy in configuration.ServerConfiguration.UserTokenPolicies) { + if (policy.SecurityPolicyUri != null && + !configuration.SecurityConfiguration.SupportedSecurityPolicies.Contains(policy.SecurityPolicyUri)) + { + m_logger.LogWarning( + "The UserTokenPolicy {Policy} specifies a security policy {SecurityPolicyUri} that is not supported by the server. " + + "Please check if all necessary application certificates are configured", + policy.ToString(), + policy.SecurityPolicyUri); + } + var clone = (UserTokenPolicy)policy.Clone(); if (string.IsNullOrEmpty(policy.SecurityPolicyUri) && diff --git a/Tests/Opc.Ua.Client.Tests/MixedSecurityPolicyTests.cs b/Tests/Opc.Ua.Client.Tests/MixedSecurityPolicyTests.cs new file mode 100644 index 000000000..e77dc7d38 --- /dev/null +++ b/Tests/Opc.Ua.Client.Tests/MixedSecurityPolicyTests.cs @@ -0,0 +1,120 @@ +/* ======================================================================== + * 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.Linq; +using System.Threading.Tasks; +using NUnit.Framework; + +namespace Opc.Ua.Client.Tests +{ + [TestFixture] + [Category("Client")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + public class MixedSecurityPolicyTests : ClientTestFramework + { + public MixedSecurityPolicyTests() + : base(Utils.UriSchemeOpcTcp) + { + } + + [OneTimeSetUp] + public override Task OneTimeSetUpAsync() + { + return base.OneTimeSetUpAsync(); + } + + [OneTimeTearDown] + public override Task OneTimeTearDownAsync() + { + return base.OneTimeTearDownAsync(); + } + + [Test] + public Task Connect_ECCEndpoint_RSAUserTokenAsync() + { + return Connect_Endpoint_UserECCRSATokenAsync(true); + } + + [Test] + public Task Connect_RsaEndpoint_EccUserTokenAsync() + { + return Connect_Endpoint_UserECCRSATokenAsync(false); + } + + private async Task Connect_Endpoint_UserECCRSATokenAsync(bool useEccEndpoint) + { + // 1. Get endpoints to find the PolicyId + var endpointConfiguration = EndpointConfiguration.Create(ClientFixture.Config); + using DiscoveryClient discoveryClient = await DiscoveryClient.CreateAsync( + ServerUrl, + endpointConfiguration, + Telemetry).ConfigureAwait(false); + + EndpointDescriptionCollection endpoints = await discoveryClient.GetEndpointsAsync(null).ConfigureAwait(false); + + // 2. Find RSA endpoint + EndpointDescription rsaEndpoint = endpoints.FirstOrDefault(e => + (useEccEndpoint ^ !EccUtils.IsEccPolicy(e.SecurityPolicyUri)) && + e.SecurityMode == MessageSecurityMode.SignAndEncrypt); + + Assert.That(rsaEndpoint, Is.Not.Null, "RSA Endpoint with RSA Policy and SignAndEncrypt not found"); + + var configuredEndpoint = new ConfiguredEndpoint(null, rsaEndpoint, endpointConfiguration); + + // 3. Find ECC UserTokenPolicy + UserTokenPolicy userTokenPolicy = rsaEndpoint.UserIdentityTokens.FirstOrDefault(p => + p.TokenType == UserTokenType.UserName && + (useEccEndpoint ^ EccUtils.IsEccPolicy(p.SecurityPolicyUri))); + + Assert.That(userTokenPolicy, Is.Not.Null, $"UserTokenPolicy with ECC SecurityPolicy not found on RSA Endpoint."); + TestContext.WriteLine($"Using UserTokenPolicy: {userTokenPolicy.PolicyId} ({userTokenPolicy.SecurityPolicyUri})"); + + // 4. Create UserIdentity and enforce PolicyId + var identity = new UserIdentity("user1", System.Text.Encoding.UTF8.GetBytes("password")); + identity.PolicyId = userTokenPolicy.PolicyId; + + // 5. Connect + ISession session = await ClientFixture.ConnectAsync( + configuredEndpoint, + identity).ConfigureAwait(false); + + try + { + Assert.That(session, Is.Not.Null); + Assert.That(session.Connected, Is.True); + } + finally + { + await session.CloseAsync().ConfigureAwait(false); + } + } + } +} From c69d4ad33a7b34ec70475bb3963864053bc3260c Mon Sep 17 00:00:00 2001 From: Roman Ettlinger Date: Wed, 11 Feb 2026 08:50:56 +0100 Subject: [PATCH 2/3] Support ECC Policy for User Token and none security if no RSA Certificate is present --- Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs b/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs index f7c74a65b..989163f7f 100644 --- a/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs +++ b/Stack/Opc.Ua.Core/Stack/Server/ServerBase.cs @@ -908,7 +908,18 @@ public virtual UserTokenPolicyCollection GetUserTokenPolicies( else { // ensure a security policy is specified for user tokens. - clone.SecurityPolicyUri = SecurityPolicies.Basic256Sha256; + if (configuration.SecurityConfiguration.SupportedSecurityPolicies.Contains(SecurityPolicies.Basic256Sha256)) + { + clone.SecurityPolicyUri = SecurityPolicies.Basic256Sha256; + } + else if (configuration.SecurityConfiguration.SupportedSecurityPolicies.Contains(SecurityPolicies.ECC_nistP256)) + { + clone.SecurityPolicyUri = SecurityPolicies.ECC_nistP256; + } + else + { + clone.SecurityPolicyUri = SecurityPolicies.Basic256Sha256; + } } } From 755ecd808a4ff53a2c91eaf4932b04b472227fd8 Mon Sep 17 00:00:00 2001 From: Roman Ettlinger Date: Thu, 12 Feb 2026 16:26:25 +0100 Subject: [PATCH 3/3] fix tests --- .../Stack/Server/ServerBaseTests.cs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Tests/Opc.Ua.Core.Tests/Stack/Server/ServerBaseTests.cs b/Tests/Opc.Ua.Core.Tests/Stack/Server/ServerBaseTests.cs index e0aa5a7f7..7f2758831 100644 --- a/Tests/Opc.Ua.Core.Tests/Stack/Server/ServerBaseTests.cs +++ b/Tests/Opc.Ua.Core.Tests/Stack/Server/ServerBaseTests.cs @@ -135,6 +135,26 @@ protected void OneTimeSetUp() ServerConfiguration = new ServerConfiguration() }; + configuration.SecurityConfiguration.ApplicationCertificates = new CertificateIdentifierCollection + { + new CertificateIdentifier + { + CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType + }, + new CertificateIdentifier + { + CertificateType = ObjectTypeIds.EccNistP256ApplicationCertificateType + }, + new CertificateIdentifier + { + CertificateType = ObjectTypeIds.EccNistP384ApplicationCertificateType + }, + new CertificateIdentifier + { + CertificateType = ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType + } + }; + // base addresses, uses localhost. specify multiple endpoints per protocol as per config. configuration.ServerConfiguration.BaseAddresses.Add( Utils.ReplaceLocalhost("opc.https://localhost:62540/UA/SampleServer"));