From 341bbb71ec54001047cd25f495792055a9ceab72 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:27:03 +0100 Subject: [PATCH] [GDS] Allow Global Discovery Server to Work with Custom Certificate Groups (#3589) * Initial plan * Extend GDS to support custom certificate groups with dynamic node creation Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Use CertificateGroup.Id to select predefined or create custom group nodes Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> * Add integration tests for custom certificate group in GDS address space Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: romanett <7413710+romanett@users.noreply.github.com> --- .../ApplicationsNodeManager.cs | 83 ++++---- .../Opc.Ua.Gds.Tests/CertificateGroupTests.cs | 18 ++ Tests/Opc.Ua.Gds.Tests/Common.cs | 6 +- .../CustomCertificateGroupIntegrationTest.cs | 197 ++++++++++++++++++ .../GlobalDiscoveryTestServer.cs | 16 +- 5 files changed, 277 insertions(+), 43 deletions(-) create mode 100644 Tests/Opc.Ua.Gds.Tests/CustomCertificateGroupIntegrationTest.cs diff --git a/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs b/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs index bc672c23a9..5cd3f5fc24 100644 --- a/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs +++ b/Libraries/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs @@ -1819,7 +1819,9 @@ namespace Opc.Ua.Gds.Server protected void SetCertificateGroupNodes(ICertificateGroup certificateGroup) { certificateGroup.DefaultTrustList = null; - if (certificateGroup.CertificateTypes.Contains(Ua.ObjectTypeIds.HttpsCertificateType)) + string groupId = certificateGroup.Configuration.Id; + + if (string.Equals(groupId, "DefaultHttpsGroup", StringComparison.OrdinalIgnoreCase)) { certificateGroup.Id = m_defaultHttpsGroupId; certificateGroup.DefaultTrustList = FindPredefinedNode( @@ -1828,8 +1830,7 @@ namespace Opc.Ua.Gds.Server Server.NamespaceUris )); } - else if (certificateGroup.CertificateTypes - .Contains(Ua.ObjectTypeIds.UserCertificateType)) + else if (string.Equals(groupId, "DefaultUserTokenGroup", StringComparison.OrdinalIgnoreCase)) { certificateGroup.Id = m_defaultUserTokenGroupId; certificateGroup.DefaultTrustList = FindPredefinedNode( @@ -1838,40 +1839,8 @@ namespace Opc.Ua.Gds.Server Server.NamespaceUris )); } - else if (certificateGroup.CertificateTypes.Any(certificateType => - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.ApplicationCertificateType) || - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.RsaMinApplicationCertificateType) || - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.RsaSha256ApplicationCertificateType) || - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.EccApplicationCertificateType) || - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.EccNistP256ApplicationCertificateType) || - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.EccNistP384ApplicationCertificateType) || - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType) || - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType) || -#if CURVE25519 - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.EccCurve25519ApplicationCertificateType) || - Utils.IsEqual( - certificateType, - Ua.ObjectTypeIds.EccCurve448ApplicationCertificateType) || -#endif - false)) + else if (string.Equals(groupId, "Default", StringComparison.OrdinalIgnoreCase) || + string.Equals(groupId, "DefaultApplicationGroup", StringComparison.OrdinalIgnoreCase)) { certificateGroup.Id = m_defaultApplicationGroupId; certificateGroup.DefaultTrustList = FindPredefinedNode( @@ -1882,9 +1851,43 @@ namespace Opc.Ua.Gds.Server } else { - throw new NotImplementedException( - $"Unknown certificate type {string.Join(",", certificateGroup.CertificateTypes.Select(n => n.ToString()))}. " + - "Use ApplicationCertificateType, HttpsCertificateType or UserCredentialCertificateType"); + // Create a new custom certificate group node in the address space + // for any group whose Id does not match one of the three predefined groups. + var certGroupsFolder = FindPredefinedNode( + ExpandedNodeId.ToNodeId(ObjectIds.Directory_CertificateGroups, Server.NamespaceUris)); + + if (certGroupsFolder == null) + { + throw new ServiceResultException( + StatusCodes.BadInternalError, + "CertificateGroups folder node was not found in the address space."); + } + + var customGroupNode = new Ua.CertificateGroupState(certGroupsFolder); + customGroupNode.Create( + SystemContext, + NodeId.Null, + new QualifiedName(groupId, NamespaceIndex), + new LocalizedText(groupId), + true); + + // Read back the NodeId assigned by Create (assignNodeIds: true reassigns the root id). + certificateGroup.Id = customGroupNode.NodeId; + + if (customGroupNode.CertificateTypes != null) + { + customGroupNode.CertificateTypes.Value = [.. certificateGroup.CertificateTypes]; + } + + certGroupsFolder.AddChild(customGroupNode); + AddPredefinedNode(SystemContext, customGroupNode); + + certificateGroup.DefaultTrustList = customGroupNode.TrustList; + + m_logger.LogInformation( + "Created custom certificate group node: {Id} with NodeId {NodeId}", + groupId, + certificateGroup.Id); } certificateGroup.DefaultTrustList?.Handle = new TrustList( diff --git a/Tests/Opc.Ua.Gds.Tests/CertificateGroupTests.cs b/Tests/Opc.Ua.Gds.Tests/CertificateGroupTests.cs index 3cb7c61841..a94021b2d9 100644 --- a/Tests/Opc.Ua.Gds.Tests/CertificateGroupTests.cs +++ b/Tests/Opc.Ua.Gds.Tests/CertificateGroupTests.cs @@ -389,5 +389,23 @@ namespace Opc.Ua.Gds.Tests Assert.AreEqual(1, crls.Count); } } + + [Test] + public void TestUnknownCertificateTypeStringThrowsNotImplemented() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + + // A certificate type string that is not a well-known OPC UA type name should throw NotImplementedException + var configuration = new CertificateGroupConfiguration + { + SubjectName = "CN=GDS Custom CA, O=OPC Foundation", + BaseStorePath = m_path, + CertificateTypes = ["NotAKnownCertificateType"] + }; + + Assert.That( + () => new CertificateGroup(telemetry).Create(m_path + "/authorities", configuration), + Throws.TypeOf()); + } } } diff --git a/Tests/Opc.Ua.Gds.Tests/Common.cs b/Tests/Opc.Ua.Gds.Tests/Common.cs index 0117caae56..de3e67916b 100644 --- a/Tests/Opc.Ua.Gds.Tests/Common.cs +++ b/Tests/Opc.Ua.Gds.Tests/Common.cs @@ -36,6 +36,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua.Configuration; using Opc.Ua.Gds.Client; +using Opc.Ua.Gds.Server; using Opc.Ua.Server.Tests; using Opc.Ua.Test; using Opc.Ua.Tests; @@ -400,7 +401,8 @@ namespace Opc.Ua.Gds.Tests public static async Task StartGDSAsync( bool clean, string storeType = CertificateStoreType.Directory, - int maxTrustListSize = 0) + int maxTrustListSize = 0, + IEnumerable additionalCertGroups = null) { GlobalDiscoveryTestServer server = null; int testPort = ServerFixtureUtils.GetNextFreeIPPort(); @@ -411,7 +413,7 @@ namespace Opc.Ua.Gds.Tests try { server = new GlobalDiscoveryTestServer(true, NUnitTelemetryContext.Create(true), maxTrustListSize); - await server.StartServerAsync(clean, testPort, storeType).ConfigureAwait(false); + await server.StartServerAsync(clean, testPort, storeType, additionalCertGroups).ConfigureAwait(false); } catch (ServiceResultException sre) { diff --git a/Tests/Opc.Ua.Gds.Tests/CustomCertificateGroupIntegrationTest.cs b/Tests/Opc.Ua.Gds.Tests/CustomCertificateGroupIntegrationTest.cs new file mode 100644 index 0000000000..af9d10c08a --- /dev/null +++ b/Tests/Opc.Ua.Gds.Tests/CustomCertificateGroupIntegrationTest.cs @@ -0,0 +1,197 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.Gds.Server; +using Opc.Ua.Tests; + +namespace Opc.Ua.Gds.Tests +{ + /// + /// Integration tests verifying that custom certificate groups are loaded from + /// configuration and correctly instantiated in the GDS address space. + /// + [TestFixture] + [NonParallelizable] + public class CustomCertificateGroupIntegrationTest + { + private const string kCustomGroupId = "MyCustomGroup"; + private const string kCustomGroupSubjectName = "CN=GDS Custom CA, O=OPC Foundation"; + + private GlobalDiscoveryTestServer m_server; + private GlobalDiscoveryTestClient m_gdsClient; + private ITelemetryContext m_telemetry; + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + m_telemetry = NUnitTelemetryContext.Create(); + + string customBasePath = Path.Combine(Path.GetTempPath(), "OPC", "GDS", "CA", "custom"); + var customGroup = new CertificateGroupConfiguration + { + Id = kCustomGroupId, + CertificateTypes = ["RsaSha256ApplicationCertificateType"], + SubjectName = kCustomGroupSubjectName, + BaseStorePath = customBasePath, + DefaultCertificateLifetime = 12, + DefaultCertificateKeySize = 2048, + DefaultCertificateHashSize = 256, + CACertificateLifetime = 60, + CACertificateKeySize = 4096, + CACertificateHashSize = 512 + }; + + m_server = await TestUtils.StartGDSAsync( + clean: true, + additionalCertGroups: [customGroup]).ConfigureAwait(false); + + m_gdsClient = new GlobalDiscoveryTestClient(true, m_telemetry); + await m_gdsClient.LoadClientConfigurationAsync(m_server.BasePort).ConfigureAwait(false); + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + if (m_gdsClient != null) + { + await m_gdsClient.DisconnectClientAsync().ConfigureAwait(false); + m_gdsClient.Dispose(); + m_gdsClient = null; + } + if (m_server != null) + { + await m_server.StopServerAsync().ConfigureAwait(false); + m_server = null; + } + } + + [Test] + public async Task CustomCertificateGroupAppearsInGetCertificateGroupsAsync() + { + // Connect as admin and register an application + m_gdsClient.GDSClient.AdminCredentials = m_gdsClient.AdminUser; + await m_gdsClient.GDSClient.ConnectAsync().ConfigureAwait(false); + + try + { + var appTestDataGenerator = new ApplicationTestDataGenerator(1, m_telemetry); + ApplicationTestData appData = appTestDataGenerator.ApplicationTestSet(1, false)[0]; + NodeId appId = await m_gdsClient.GDSClient + .RegisterApplicationAsync(appData.ApplicationRecord) + .ConfigureAwait(false); + + Assert.That(appId, Is.Not.Null); + Assert.That(appId.IsNull, Is.False); + + // GetCertificateGroups should return both the default group and the custom group + NodeId[] groups = await m_gdsClient.GDSClient + .GetCertificateGroupsAsync(appId) + .ConfigureAwait(false); + + Assert.That(groups, Is.Not.Null); + Assert.That(groups.Length, Is.EqualTo(2), + "Expected 2 certificate groups: default + custom"); + + // Verify that each group's TrustList is accessible + foreach (NodeId groupId in groups) + { + NodeId trustListId = await m_gdsClient.GDSClient + .GetTrustListAsync(appId, groupId) + .ConfigureAwait(false); + + Assert.That(trustListId, Is.Not.Null); + Assert.That(trustListId.IsNull, Is.False, + $"TrustList NodeId must not be null for group {groupId}"); + } + + await m_gdsClient.GDSClient.UnregisterApplicationAsync(appId).ConfigureAwait(false); + } + finally + { + await m_gdsClient.GDSClient.DisconnectAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task CustomCertificateGroupNodeExistsInAddressSpaceAsync() + { + // Connect as admin to browse the address space + m_gdsClient.GDSClient.AdminCredentials = m_gdsClient.AdminUser; + await m_gdsClient.GDSClient.ConnectAsync().ConfigureAwait(false); + + try + { + var appTestDataGenerator = new ApplicationTestDataGenerator(2, m_telemetry); + ApplicationTestData appData = appTestDataGenerator.ApplicationTestSet(1, false)[0]; + NodeId appId = await m_gdsClient.GDSClient + .RegisterApplicationAsync(appData.ApplicationRecord) + .ConfigureAwait(false); + + NodeId[] groups = await m_gdsClient.GDSClient + .GetCertificateGroupsAsync(appId) + .ConfigureAwait(false); + + // The default application group has a well-known NodeId (predefined in the GDS NodeSet) + // The custom group has a dynamically generated NodeId outside that namespace + NodeId defaultGroupId = ExpandedNodeId.ToNodeId( + ObjectIds.Directory_CertificateGroups_DefaultApplicationGroup, + m_gdsClient.GDSClient.Session.NamespaceUris); + + // Verify the custom group NodeId is among the returned groups + NodeId customGroupNodeId = groups.FirstOrDefault(g => !Utils.IsEqual(g, defaultGroupId)); + Assert.That(customGroupNodeId, Is.Not.Null, + "A group NodeId other than DefaultApplicationGroup must be present"); + Assert.That(customGroupNodeId.IsNull, Is.False, + "The custom group NodeId must not be null"); + + // Read the BrowseName of the custom group node from the address space + Node customGroupNode = await m_gdsClient.GDSClient.Session + .ReadNodeAsync(customGroupNodeId) + .ConfigureAwait(false); + + Assert.That(customGroupNode, Is.Not.Null, + "The custom certificate group node must exist in the address space"); + Assert.That(customGroupNode.BrowseName.Name, Is.EqualTo(kCustomGroupId), + $"BrowseName of the custom group node must be '{kCustomGroupId}'"); + + await m_gdsClient.GDSClient.UnregisterApplicationAsync(appId).ConfigureAwait(false); + } + finally + { + await m_gdsClient.GDSClient.DisconnectAsync().ConfigureAwait(false); + } + } + } +} diff --git a/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs b/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs index d9892314c9..c6bde5bc68 100644 --- a/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs +++ b/Tests/Opc.Ua.Gds.Tests/GlobalDiscoveryTestServer.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System; +using System.Collections.Generic; using System.IO; using System.Runtime.InteropServices; using System.Threading.Tasks; @@ -58,7 +59,8 @@ namespace Opc.Ua.Gds.Tests public async Task StartServerAsync( bool clean, int basePort = -1, - string storeType = CertificateStoreType.Directory) + string storeType = CertificateStoreType.Directory, + IEnumerable additionalCertGroups = null) { ApplicationInstance.MessageDlg = new ApplicationMessageDlg(m_logger); @@ -114,6 +116,18 @@ namespace Opc.Ua.Gds.Tests Config = await LoadAsync(Application, basePort, m_maxTrustListSize).ConfigureAwait(false); } + // Inject any additional certificate groups into the configuration. + if (additionalCertGroups != null) + { + GlobalDiscoveryServerConfiguration gdsConfig = + Config.ParseExtension(); + foreach (CertificateGroupConfiguration group in additionalCertGroups) + { + gdsConfig.CertificateGroups.Add(group); + } + Config.UpdateExtension(null, gdsConfig); + } + // check the application certificate. bool haveAppCertificate = await Application .CheckApplicationInstanceCertificatesAsync(true)