[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>
This commit is contained in:
Copilot
2026-03-04 20:27:03 +01:00
committed by GitHub
co-authored by romanett
parent c19e83b63d
commit 341bbb71ec
5 changed files with 277 additions and 43 deletions
@@ -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<TrustListState>(
@@ -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<TrustListState>(
@@ -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<TrustListState>(
@@ -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<Ua.CertificateGroupFolderState>(
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(
@@ -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<NotImplementedException>());
}
}
}
+4 -2
View File
@@ -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<GlobalDiscoveryTestServer> StartGDSAsync(
bool clean,
string storeType = CertificateStoreType.Directory,
int maxTrustListSize = 0)
int maxTrustListSize = 0,
IEnumerable<CertificateGroupConfiguration> 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)
{
@@ -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
{
/// <summary>
/// Integration tests verifying that custom certificate groups are loaded from
/// configuration and correctly instantiated in the GDS address space.
/// </summary>
[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);
}
}
}
}
@@ -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<CertificateGroupConfiguration> 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<GlobalDiscoveryServerConfiguration>();
foreach (CertificateGroupConfiguration group in additionalCertGroups)
{
gdsConfig.CertificateGroups.Add(group);
}
Config.UpdateExtension<GlobalDiscoveryServerConfiguration>(null, gdsConfig);
}
// check the application certificate.
bool haveAppCertificate = await Application
.CheckApplicationInstanceCertificatesAsync(true)