mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 22:55:49 +08:00
[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:
@@ -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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user