Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/ConfigurationNodeManagerPushTests.cs
2026-07-16 06:25:54 +02:00

3635 lines
178 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 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.Reflection;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Server.TestFramework;
using Opc.Ua.Tests;
using Quickstarts.ReferenceServer;
namespace Opc.Ua.Server.Tests
{
/// <summary>
/// Tests that exercise the certificate-push method handlers bound by
/// <see cref="ConfigurationNodeManager"/> onto the <c>ServerConfiguration</c>
/// node (UpdateCertificate, CreateSigningRequest, CreateSelfSignedCertificate,
/// ApplyChanges, GetRejectedList, GetCertificates) as well as the alarm
/// monitoring and namespace-metadata cache helpers.
/// </summary>
[TestFixture]
[Category("ConfigurationNodeManager")]
[NonParallelizable]
[Parallelizable(ParallelScope.None)]
public class ConfigurationNodeManagerPushTests
{
private static readonly ITelemetryContext s_telemetry = NUnitTelemetryContext.Create();
// A §7.10.10-compliant (>= 32 byte) additional-entropy nonce for
// CreateSigningRequest regeneratePrivateKey requests.
private static readonly ByteString s_regenerateNonce = ByteString.From(
[
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F
]);
private ServerFixture<StandardServer> m_fixture;
private StandardServer m_server;
private ConfigurationNodeManager m_configManager;
private ServerConfigurationState m_configNode;
[OneTimeSetUp]
public async Task OneTimeSetUpAsync()
{
m_fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
m_server = await m_fixture.StartAsync().ConfigureAwait(false);
IServerInternal serverInternal = m_server.CurrentInstance;
m_configManager = serverInternal.ConfigurationNodeManager as ConfigurationNodeManager;
Assert.That(m_configManager, Is.Not.Null, "ConfigurationNodeManager not found or not of expected type");
NodeState node = await serverInternal.NodeManager
.FindNodeInAddressSpaceAsync(ObjectIds.ServerConfiguration)
.ConfigureAwait(false);
m_configNode = node as ServerConfigurationState;
Assert.That(m_configNode, Is.Not.Null, "ServerConfiguration node not found");
}
[OneTimeTearDown]
public async Task OneTimeTearDownAsync()
{
if (m_fixture != null)
{
await m_fixture.StopAsync().ConfigureAwait(false);
}
}
[TearDown]
public async Task TearDownAsync()
{
// Several tests stage a certificate/TrustList change without
// calling ApplyChanges. Cancel any transaction left active so
// the next test in this shared-fixture file always starts from
// a clean, deterministic state (CancelChanges is a harmless
// no-op returning BadNothingToDo when nothing is staged).
if (m_configNode?.CancelChanges != null)
{
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult result = await m_configNode.CancelChanges.OnCallMethod2Async(
CreateAdminContext(),
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
if (result.StatusCode == StatusCodes.BadSessionIdInvalid)
{
GetCoordinatorField(m_configManager).Reset();
}
}
}
[Test]
public void GetRejectedListReturnsGoodForAdmin()
{
ISystemContext context = CreateAdminContext();
ArrayOf<ByteString> certificates = default;
ServiceResult result = m_configNode.GetRejectedList.OnCall(
context,
m_configNode.GetRejectedList,
m_configNode.NodeId,
ref certificates);
Assert.That(ServiceResult.IsGood(result), Is.True);
}
[Test]
public void GetRejectedListNonAdminThrowsBadUserAccessDenied()
{
ISystemContext context = CreateAnonymousContext();
ArrayOf<ByteString> certificates = default;
ServiceResultException exception = Assert.Throws<ServiceResultException>(() =>
m_configNode.GetRejectedList.OnCall(
context,
m_configNode.GetRejectedList,
m_configNode.NodeId,
ref certificates));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
// NOTE: A test that pre-seeds the rejected-certificate store and then
// calls GetRejectedList to exercise the non-empty enumeration branch
// is intentionally NOT included. DirectoryCertificateStore.Load
// (invoked from GetRejectedList's `store.EnumerateAsync()` at
// ConfigurationNodeManager.cs) constructs a Certificate per stored
// entry that is never disposed by the enclosing `using
// CertificateCollection` -- CertificateCollection.Dispose() does not
// cascade to its members. This is a genuine, pre-existing resource
// leak below ConfigurationNodeManager (in
// Opc.Ua.Core's DirectoryCertificateStore /
// CertificateCollection), reproducible deterministically as soon as
// the rejected store is non-empty. It trips the assembly-wide
// Opc.Ua.Server.Tests.LeakDetectionSetup certificate-leak assertion
// and is out of scope to fix here (production code change required,
// outside ConfigurationNodeManager.cs). The empty-store success path
// and the non-admin failure path are still covered below.
[Test]
public void GetCertificatesForDefaultApplicationGroupReturnsGood()
{
ISystemContext context = CreateAdminContext();
ArrayOf<NodeId> certificateTypeIds = default;
ArrayOf<ByteString> certificates = default;
ServiceResult result = m_configNode.GetCertificates.OnCall(
context,
m_configNode.GetCertificates,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIds,
ref certificates);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.That(certificateTypeIds.Count, Is.GreaterThan(0));
Assert.That(certificates.Count, Is.EqualTo(certificateTypeIds.Count));
}
[Test]
public void GetCertificatesWithInvalidGroupThrowsBadInvalidArgument()
{
ISystemContext context = CreateAdminContext();
ArrayOf<NodeId> certificateTypeIds = default;
ArrayOf<ByteString> certificates = default;
ServiceResultException exception = Assert.Throws<ServiceResultException>(() =>
m_configNode.GetCertificates.OnCall(
context,
m_configNode.GetCertificates,
m_configNode.NodeId,
new NodeId(Guid.NewGuid(), 1),
ref certificateTypeIds,
ref certificates));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public void GetCertificatesNonAdminThrowsBadUserAccessDenied()
{
ISystemContext context = CreateAnonymousContext();
ArrayOf<NodeId> certificateTypeIds = default;
ArrayOf<ByteString> certificates = default;
ServiceResultException exception = Assert.Throws<ServiceResultException>(() =>
m_configNode.GetCertificates.OnCall(
context,
m_configNode.GetCertificates,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIds,
ref certificates));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public void CreateSelfSignedCertificateWithEmptyDnsAndIpAddressesThrowsBadInvalidArgument()
{
// OPC 10000-12 §7.10.6: "There shall be at least one entry in
// dnsName or IP address lists," regardless of certificate type
// or whether SubjectName was supplied.
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSelfSignedCertificate.OnCallAsync(
context,
m_configNode.CreateSelfSignedCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
string.Empty,
[],
[],
0,
0,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public void CreateSelfSignedCertificateWithEmptySubjectNameForApplicationCertificateTypeComputesDefault()
{
// OPC 10000-12 §7.10.6/§7.10.21: for ApplicationCertificateTypes
// the SubjectName may be omitted; the Server derives a suitable
// default rather than rejecting the request with
// BadInvalidArgument. The shared fixture's DefaultApplicationGroup
// RsaSha256 slot is already occupied, so - once the (now
// permitted) empty-subject request passes validation - it must
// still reach (and fail with) the pre-existing occupied-slot
// check, proving the old blanket SubjectName-required rejection
// no longer fires first.
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSelfSignedCertificate.OnCallAsync(
context,
m_configNode.CreateSelfSignedCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
string.Empty,
["localhost", string.Empty],
["127.0.0.1", string.Empty],
(ushort)0,
(ushort)2048,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
}
[Test]
public void CreateSelfSignedCertificateWithUnsupportedKeySizeThrowsBadOutOfRange()
{
// OPC 10000-12 §7.10.6: "Bad_OutOfRange: The keySizeInBits is
// not supported." RsaSha256ApplicationCertificateType only
// supports 2048/3072/4096; 1024 is not one of them.
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSelfSignedCertificate.OnCallAsync(
context,
m_configNode.CreateSelfSignedCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
"CN=Unsupported Key Size Probe",
["localhost"],
["127.0.0.1"],
(ushort)0,
(ushort)1024,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadOutOfRange));
}
[Test]
public void CreateSelfSignedCertificateNonAdminThrowsBadUserAccessDenied()
{
ISystemContext context = CreateAnonymousContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSelfSignedCertificate.OnCallAsync(
context,
m_configNode.CreateSelfSignedCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
"CN=Test",
[],
[],
0,
0,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public void CreateSelfSignedCertificateWithInvalidGroupThrowsBadInvalidArgument()
{
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSelfSignedCertificate.OnCallAsync(
context,
m_configNode.CreateSelfSignedCertificate,
m_configNode.NodeId,
new NodeId(Guid.NewGuid(), 1),
ObjectTypeIds.RsaSha256ApplicationCertificateType,
"CN=Test",
[],
[],
0,
0,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public async Task CreateSelfSignedCertificateWithDefaultLifetimeReturnsCertificateAsync()
{
ISystemContext context = CreateAdminContext();
// OPC 10000-12 §7.10.6: the shared fixture's DefaultApplicationGroup
// already has an active RsaSha256 certificate (the server's own
// application certificate) — CreateSelfSignedCertificate must never
// replace an occupied slot.
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSelfSignedCertificate.OnCallAsync(
context,
m_configNode.CreateSelfSignedCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
"CN=ConfigurationNodeManager Self Signed Occupied Probe",
["localhost", string.Empty],
["127.0.0.1", string.Empty],
(ushort)0,
(ushort)2048,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception, Is.Not.Null, "expected BadInvalidState but call succeeded");
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
}
[Test]
public async Task DeleteCertificateReferencedByEndpointIsRejectedByApplyChangesAsync()
{
// OPC 10000-12 §7.10.7: "Certificates that are referenced by
// EndpointDescriptions shall not be deleted. This determination
// happens when ApplyChanges is called." The server's RSA
// application certificate is referenced by its secure endpoints,
// so staging its deletion succeeds but ApplyChanges must reject
// the transaction and leave the certificate in place.
//
// Isolated fixture so the (rejected) deletion cannot affect any
// test sharing m_configManager.
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
StandardServer server = null;
try
{
server = await fixture.StartAsync().ConfigureAwait(false);
NodeState node = await server.CurrentInstance.NodeManager
.FindNodeInAddressSpaceAsync(ObjectIds.ServerConfiguration)
.ConfigureAwait(false);
var configNode = node as ServerConfigurationState;
Assert.That(configNode, Is.Not.Null);
var configManager = server.CurrentInstance.ConfigurationNodeManager as ConfigurationNodeManager;
Assert.That(configManager, Is.Not.Null);
ISystemContext context = CreateAdminContext();
ArrayOf<NodeId> typesBefore = default;
ArrayOf<ByteString> certsBefore = default;
Assert.That(
ServiceResult.IsGood(configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref typesBefore,
ref certsBefore)),
Is.True);
int rsaBefore = typesBefore.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(rsaBefore, Is.GreaterThanOrEqualTo(0));
using Certificate referencedCertificate = Certificate.FromRawData(certsBefore[rsaBefore]);
DeleteCertificateMethodStateResult deleteResult = await configNode
.DeleteCertificate.OnCallAsync(
context,
configNode.DeleteCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(
ServiceResult.IsGood(deleteResult.ServiceResult),
Is.True,
"staging DeleteCertificate must succeed; §7.10.7 is enforced at ApplyChanges");
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult applyResult = await configNode.ApplyChanges.OnCallMethod2Async(
context,
configNode.ApplyChanges,
configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(
applyResult.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadInvalidState),
"deleting an endpoint-referenced certificate must be rejected at ApplyChanges (§7.10.7)");
await configManager.DrainPendingApplyChangesAsync(CancellationToken.None).ConfigureAwait(false);
// The rejected transaction must have left the certificate.
ArrayOf<NodeId> typesAfter = default;
ArrayOf<ByteString> certsAfter = default;
Assert.That(
ServiceResult.IsGood(configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref typesAfter,
ref certsAfter)),
Is.True);
int rsaAfter = typesAfter.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(
rsaAfter,
Is.GreaterThanOrEqualTo(0),
"the referenced certificate must survive the rejected delete");
using Certificate afterCertificate = Certificate.FromRawData(certsAfter[rsaAfter]);
Assert.That(afterCertificate.Thumbprint, Is.EqualTo(referencedCertificate.Thumbprint));
}
finally
{
if (server != null)
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
}
[Test]
public async Task RotatedCertificateBecomesEndpointReferencedAndCannotBeDeletedAsync()
{
// OPC 10000-12 §7.10.7 regression: the endpoint-reference check must
// resolve the certificate each EndpointDescription presents from the
// live certificate registry at ApplyChanges time, not from the
// EndpointDescription.ServerCertificate blob captured at startup.
// After rotating the RSA application certificate A -> B, deleting B
// (the certificate now presented by the secure endpoints) must be
// rejected at ApplyChanges even though the startup blob referenced A.
//
// Multi-slot configuration: the ECC slots stay occupied so the
// conservative "last remaining certificate" staging check passes and
// the rejection is produced specifically by the endpoint-reference
// determination. Isolated fixture so the rotation/rejected delete
// cannot affect any test sharing m_configManager.
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
StandardServer server = null;
try
{
server = await fixture.StartAsync().ConfigureAwait(false);
NodeState node = await server.CurrentInstance.NodeManager
.FindNodeInAddressSpaceAsync(ObjectIds.ServerConfiguration)
.ConfigureAwait(false);
var configNode = node as ServerConfigurationState;
Assert.That(configNode, Is.Not.Null);
var configManager = server.CurrentInstance.ConfigurationNodeManager as ConfigurationNodeManager;
Assert.That(configManager, Is.Not.Null);
ISystemContext context = CreateAdminContext();
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
// Capture the certificate the RSA slot holds at startup (A).
ArrayOf<NodeId> typesBefore = default;
ArrayOf<ByteString> certsBefore = default;
Assert.That(
ServiceResult.IsGood(configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref typesBefore,
ref certsBefore)),
Is.True);
int rsaBefore = typesBefore.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(rsaBefore, Is.GreaterThanOrEqualTo(0));
using Certificate certificateA = Certificate.FromRawData(certsBefore[rsaBefore]);
string[] domainNames = X509Utils.GetDomainsFromCertificate(certificateA).ToArray();
// Rotate A -> B via UpdateCertificate + ApplyChanges. B keeps the
// same subject so it stays the RSA application certificate.
using Certificate certificateB = DefaultCertificateFactory.Instance
.CreateApplicationCertificate(
fixture.Config.ApplicationUri,
fixture.Config.ApplicationName,
certificateA.Subject,
domainNames)
.CreateForRSA();
Assert.That(
certificateB.Thumbprint,
Is.Not.EqualTo(certificateA.Thumbprint),
"the rotation must produce a genuinely different certificate");
ByteString privateKeyB = certificateB.Export(X509ContentType.Pfx).ToByteString();
UpdateCertificateMethodStateResult updateResult = await configNode.UpdateCertificate.OnCallAsync(
context,
configNode.UpdateCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
certificateB.RawData.ToByteString(),
ArrayOf<ByteString>.Empty,
"pfx",
privateKeyB,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(updateResult.ServiceResult), Is.True);
ServiceResult rotateApplyResult = await configNode.ApplyChanges.OnCallMethod2Async(
context,
configNode.ApplyChanges,
configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(
ServiceResult.IsGood(rotateApplyResult),
Is.True,
"rotating the RSA application certificate must succeed");
await configManager.DrainPendingApplyChangesAsync(CancellationToken.None).ConfigureAwait(false);
// The RSA slot now presents B.
ArrayOf<NodeId> typesRotated = default;
ArrayOf<ByteString> certsRotated = default;
Assert.That(
ServiceResult.IsGood(configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref typesRotated,
ref certsRotated)),
Is.True);
int rsaRotated = typesRotated.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(rsaRotated, Is.GreaterThanOrEqualTo(0));
using (Certificate rotatedCert = Certificate.FromRawData(certsRotated[rsaRotated]))
{
Assert.That(
rotatedCert.Thumbprint,
Is.EqualTo(certificateB.Thumbprint),
"the RSA slot must hold the rotated certificate B after ApplyChanges");
}
// Staging DeleteCertificate(B) succeeds: the ECC slots keep the
// "last remaining certificate" staging check satisfied.
DeleteCertificateMethodStateResult deleteResult = await configNode
.DeleteCertificate.OnCallAsync(
context,
configNode.DeleteCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(
ServiceResult.IsGood(deleteResult.ServiceResult),
Is.True,
"staging DeleteCertificate must succeed; §7.10.7 is enforced at ApplyChanges");
// ApplyChanges must reject deleting the rotated, endpoint-
// referenced certificate B.
ServiceResult deleteApplyResult = await configNode.ApplyChanges.OnCallMethod2Async(
context,
configNode.ApplyChanges,
configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(
deleteApplyResult.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadInvalidState),
"deleting the rotated (currently presented) certificate must be rejected at " +
"ApplyChanges (§7.10.7)");
await configManager.DrainPendingApplyChangesAsync(CancellationToken.None).ConfigureAwait(false);
// B must survive the rejected delete.
ArrayOf<NodeId> typesAfter = default;
ArrayOf<ByteString> certsAfter = default;
Assert.That(
ServiceResult.IsGood(configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref typesAfter,
ref certsAfter)),
Is.True);
int rsaAfter = typesAfter.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(
rsaAfter,
Is.GreaterThanOrEqualTo(0),
"the referenced rotated certificate must survive the rejected delete");
using Certificate afterCertificate = Certificate.FromRawData(certsAfter[rsaAfter]);
Assert.That(afterCertificate.Thumbprint, Is.EqualTo(certificateB.Thumbprint));
}
finally
{
if (server != null)
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
}
[Test]
public async Task DeleteCertificateThenCreateSelfSignedCertificateInSameTransactionReplacesTheSlotAsync()
{
// Issue: IsSlotOccupiedAsync only ever checked the live
// store/registry state, ignoring every operation already
// staged (but not yet committed) in the active transaction.
// Staging DeleteCertificate followed by
// CreateSelfSignedCertificate for the same slot in the same
// transaction therefore always failed with BadInvalidState,
// even though the staged delete - once committed - empties
// exactly the slot CreateSelfSignedCertificate is about to
// (re)populate. Verify the second call is now permitted, and
// that ApplyChanges commits a clean replacement: the original
// certificate is gone (no orphan left in the application
// store) and the new self-signed certificate alone occupies
// the slot.
//
// Isolated fixture: this permanently replaces the server's
// own application-certificate slot, which would break every
// other test sharing m_configManager.
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
StandardServer server = null;
try
{
server = await fixture.StartAsync().ConfigureAwait(false);
NodeState node = await server.CurrentInstance.NodeManager
.FindNodeInAddressSpaceAsync(ObjectIds.ServerConfiguration)
.ConfigureAwait(false);
var configNode = node as ServerConfigurationState;
Assert.That(configNode, Is.Not.Null);
var configManager = server.CurrentInstance.ConfigurationNodeManager as ConfigurationNodeManager;
Assert.That(configManager, Is.Not.Null);
ISystemContext context = CreateAdminContext();
ArrayOf<NodeId> certificateTypeIdsBefore = default;
ArrayOf<ByteString> certificatesBefore = default;
ServiceResult getBeforeResult = configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIdsBefore,
ref certificatesBefore);
Assert.That(ServiceResult.IsGood(getBeforeResult), Is.True);
int rsaIndexBefore = certificateTypeIdsBefore.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(rsaIndexBefore, Is.GreaterThanOrEqualTo(0));
using Certificate originalCertificate = Certificate.FromRawData(certificatesBefore[rsaIndexBefore]);
DeleteCertificateMethodStateResult deleteResult = await configNode
.DeleteCertificate.OnCallAsync(
context,
configNode.DeleteCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(deleteResult.ServiceResult), Is.True);
// Before the fix, this would throw BadInvalidState: the
// live store/registry still shows the slot occupied,
// since the staged delete above has not committed yet.
CreateSelfSignedCertificateMethodStateResult createResult = await configNode
.CreateSelfSignedCertificate.OnCallAsync(
context,
configNode.CreateSelfSignedCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
"CN=DeleteThenCreateSelfSigned " + Guid.NewGuid().ToString("N")[..8],
["localhost", string.Empty],
["127.0.0.1", string.Empty],
(ushort)0,
(ushort)2048,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(
ServiceResult.IsGood(createResult.ServiceResult),
Is.True,
"DeleteCertificate staged earlier in the same transaction must permit CreateSelfSignedCertificate");
using Certificate newCertificate = Certificate.FromRawData(createResult.Certificate);
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult applyResult = await configNode.ApplyChanges.OnCallMethod2Async(
context,
configNode.ApplyChanges,
configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(applyResult), Is.True, "the net replacement must commit successfully");
await configManager.DrainPendingApplyChangesAsync(CancellationToken.None).ConfigureAwait(false);
ArrayOf<NodeId> certificateTypeIdsAfter = default;
ArrayOf<ByteString> certificatesAfter = default;
ServiceResult getAfterResult = configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIdsAfter,
ref certificatesAfter);
Assert.That(ServiceResult.IsGood(getAfterResult), Is.True);
int rsaIndexAfter = certificateTypeIdsAfter.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(rsaIndexAfter, Is.GreaterThanOrEqualTo(0));
using Certificate finalCertificate = Certificate.FromRawData(certificatesAfter[rsaIndexAfter]);
Assert.That(
finalCertificate.Thumbprint,
Is.EqualTo(newCertificate.Thumbprint),
"the slot must be occupied by the newly created self-signed certificate");
Assert.That(
finalCertificate.Thumbprint,
Is.Not.EqualTo(originalCertificate.Thumbprint),
"the original certificate must have been replaced");
// No orphan: the original certificate must no longer
// resolve from the application store either.
CertificateIdentifier rsaIdentifier = fixture.Config.SecurityConfiguration.ApplicationCertificates
.ToList()
.First(c => c.CertificateType == ObjectTypeIds.RsaSha256ApplicationCertificateType);
var appStoreIdentifier = new CertificateStoreIdentifier(rsaIdentifier.StorePath!);
using ICertificateStore appStore = appStoreIdentifier.OpenStore(s_telemetry);
using CertificateCollection originalMatches = await appStore
.FindByThumbprintAsync(originalCertificate.Thumbprint).ConfigureAwait(false);
Assert.That(
originalMatches,
Has.Count.EqualTo(0),
"the replaced original certificate must not remain orphaned in the application store");
using CertificateCollection newMatches = await appStore
.FindByThumbprintAsync(newCertificate.Thumbprint).ConfigureAwait(false);
Assert.That(
newMatches,
Has.Count.EqualTo(1),
"the new self-signed certificate must be the only one present for this slot");
}
finally
{
if (server != null)
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
}
[Test]
public async Task CreateSelfSignedCertificateThenDeleteCertificateInSameTransactionAreStagedThenCancelledAsync()
{
// Issue: IsSlotOccupiedAsync only ever checked the live
// store/registry state, ignoring every operation already
// staged in the active transaction. Staging
// CreateSelfSignedCertificate followed by DeleteCertificate
// for the same (still genuinely empty) slot in the same
// transaction therefore always failed with BadInvalidArgument
// ("slot already empty"), even though the staged create -
// once committed - would occupy exactly the slot
// DeleteCertificate is about to empty again. Verify the
// second call is now permitted, and that ApplyChanges commits
// the net no-op: the slot remains empty, exactly as if
// neither call had been made.
//
// Isolated fixture: this permanently empties the server's own
// application-certificate slot, which would break every
// other test sharing m_configManager.
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
StandardServer server = null;
try
{
server = await fixture.StartAsync().ConfigureAwait(false);
NodeState node = await server.CurrentInstance.NodeManager
.FindNodeInAddressSpaceAsync(ObjectIds.ServerConfiguration)
.ConfigureAwait(false);
var configNode = node as ServerConfigurationState;
Assert.That(configNode, Is.Not.Null);
var configManager = server.CurrentInstance.ConfigurationNodeManager as ConfigurationNodeManager;
Assert.That(configManager, Is.Not.Null);
ISystemContext context = CreateAdminContext();
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
// The RSA application certificate is referenced by the
// server's secure EndpointDescriptions, so §7.10.7 forbids
// deleting it to genuinely empty its slot (see
// DeleteCertificateReferencedByEndpointIsRejectedByApplyChangesAsync).
// The net-staged-state behaviour is therefore exercised at
// staging time only, and the transaction is cancelled so the
// shared server certificate is left untouched: staging
// DeleteCertificate first nets the slot empty, which must
// permit CreateSelfSignedCertificate, which in turn nets the
// slot occupied and must permit a further DeleteCertificate.
DeleteCertificateMethodStateResult firstDelete = await configNode
.DeleteCertificate.OnCallAsync(
context,
configNode.DeleteCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(firstDelete.ServiceResult), Is.True);
CreateSelfSignedCertificateMethodStateResult createResult = await configNode
.CreateSelfSignedCertificate.OnCallAsync(
context,
configNode.CreateSelfSignedCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
"CN=CreateThenDelete " + Guid.NewGuid().ToString("N")[..8],
["localhost", string.Empty],
["127.0.0.1", string.Empty],
(ushort)0,
(ushort)2048,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(createResult.ServiceResult), Is.True);
// Before the fix, this would throw BadInvalidArgument: the
// live store/registry still shows the slot occupied, since
// the staged create above has not committed yet.
DeleteCertificateMethodStateResult deleteResult = await configNode
.DeleteCertificate.OnCallAsync(
context,
configNode.DeleteCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(
ServiceResult.IsGood(deleteResult.ServiceResult),
Is.True,
"CreateSelfSignedCertificate staged earlier in the same transaction must permit DeleteCertificate");
ServiceResult cancelResult = await configNode.CancelChanges.OnCallMethod2Async(
context,
configNode.CancelChanges,
configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(cancelResult), Is.True);
// Cancelling the transaction must leave the original,
// endpoint-referenced RSA certificate in place.
ArrayOf<NodeId> certificateTypeIdsAfter = default;
ArrayOf<ByteString> certificatesAfter = default;
ServiceResult getAfterResult = configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIdsAfter,
ref certificatesAfter);
Assert.That(ServiceResult.IsGood(getAfterResult), Is.True);
Assert.That(certificatesAfter.Count, Is.EqualTo(certificateTypeIdsAfter.Count));
int rsaIndexAfter = certificateTypeIdsAfter.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(
rsaIndexAfter,
Is.GreaterThanOrEqualTo(0),
"cancelling CreateSelfSignedCertificate + DeleteCertificate must leave the slot occupied");
}
finally
{
if (server != null)
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
}
[Test]
public async Task CreateSigningRequestWithoutRegeneratePrivateKeyReturnsGoodAsync()
{
ISystemContext context = CreateAdminContext();
CreateSigningRequestMethodStateResult result = await m_configNode
.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
string.Empty,
false,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
Assert.That(result.CertificateRequest.Length, Is.GreaterThan(0));
}
[Test]
public async Task CreateSigningRequestWithRegeneratedPrivateKeyDisposesOnShutdownAsync()
{
long leakedBefore = Certificate.InstancesLeaked;
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
StandardServer server = null;
try
{
server = await fixture.StartAsync().ConfigureAwait(false);
NodeState node = await server.CurrentInstance.NodeManager
.FindNodeInAddressSpaceAsync(ObjectIds.ServerConfiguration)
.ConfigureAwait(false);
var configNode = node as ServerConfigurationState;
Assert.That(configNode, Is.Not.Null);
CreateSigningRequestMethodStateResult result = await configNode
.CreateSigningRequest.OnCallAsync(
CreateAdminContext(),
configNode.CreateSigningRequest,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
string.Empty,
true,
s_regenerateNonce,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
Assert.That(result.CertificateRequest.Length, Is.GreaterThan(0));
}
finally
{
if (server != null)
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
Assert.That(Certificate.InstancesLeaked, Is.LessThanOrEqualTo(leakedBefore));
}
[Test]
public void CreateSigningRequestNonAdminThrowsBadUserAccessDenied()
{
ISystemContext context = CreateAnonymousContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
string.Empty,
false,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public void CreateSigningRequestWithInvalidGroupThrowsBadInvalidArgument()
{
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
new NodeId(Guid.NewGuid(), 1),
ObjectTypeIds.RsaSha256ApplicationCertificateType,
string.Empty,
false,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public void CreateSigningRequestWithNullCertificateTypeThrowsBadInvalidArgument()
{
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
NodeId.Null,
string.Empty,
false,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public async Task CreateSigningRequestWithNullGroupUsesDefaultApplicationGroupAsync()
{
ISystemContext context = CreateAdminContext();
// Passing NodeId.Null for the group must hit the "default to
// DefaultApplicationGroup" branch inside VerifyGroupAndTypeId.
CreateSigningRequestMethodStateResult result = await m_configNode
.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
NodeId.Null,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
string.Empty,
false,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
}
[Test]
public void CreateSigningRequestWithCertificateTypeNotInGroupThrowsBadInvalidArgument()
{
ISystemContext context = CreateAdminContext();
// HttpsCertificateType is only ever registered on the
// DefaultHttpsGroup, never on DefaultApplicationGroup, so this
// must hit the "certificate type not valid for group" branch.
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.HttpsCertificateType,
string.Empty,
false,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public void UpdateCertificateWithEmptyCertificateThrowsArgumentNullException()
{
ISystemContext context = CreateAdminContext();
Assert.ThrowsAsync<ArgumentNullException>(async () =>
await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
ByteString.Empty,
[],
null,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
}
[Test]
public void UpdateCertificateWithInvalidCertificateDataThrowsBadCertificateInvalid()
{
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
ByteString.From([1, 2, 3, 4]),
[],
null,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadCertificateInvalid));
}
[Test]
public async Task UpdateCertificateForApplicationCertificateGroupIgnoresMalformedIssuerCertificateDataAsync()
{
// OPC 10000-12 §7.10.5: the issuerCertificates list is never
// parsed for an ApplicationCertificate-purpose group, so even
// structurally invalid (non-DER) bytes must not fail the
// request the way they would for a purpose that still honors
// the supplied issuer chain.
ISystemContext context = CreateAdminContext();
ByteString currentCertificate = GetCurrentRsaCertificate(context);
UpdateCertificateMethodStateResult result = await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
currentCertificate,
[ByteString.From([5, 4, 3, 2, 1])],
null,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
Assert.That(result.ApplyChangesRequired, Is.True);
}
[Test]
public async Task UpdateCertificateForApplicationCertificateGroupDoesNotStageOrImportSuppliedIssuersAsync()
{
// OPC 10000-12 §7.10.5: the ignored issuerCertificates list
// must never be staged or imported into the group's issuer
// store for an ApplicationCertificate-purpose group, even
// when ApplyChanges actually commits the update. Isolated
// fixture: this replaces the server's own application
// certificate, which would affect every other test sharing
// m_configManager.
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
StandardServer server = null;
try
{
server = await fixture.StartAsync().ConfigureAwait(false);
NodeState node = await server.CurrentInstance.NodeManager
.FindNodeInAddressSpaceAsync(ObjectIds.ServerConfiguration)
.ConfigureAwait(false);
var configNode = node as ServerConfigurationState;
Assert.That(configNode, Is.Not.Null);
var configManager = server.CurrentInstance.ConfigurationNodeManager as ConfigurationNodeManager;
Assert.That(configManager, Is.Not.Null);
ISystemContext context = CreateAdminContext();
ArrayOf<NodeId> certificateTypeIds = default;
ArrayOf<ByteString> currentCertificates = default;
ServiceResult getBeforeResult = configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIds,
ref currentCertificates);
Assert.That(ServiceResult.IsGood(getBeforeResult), Is.True);
int rsaIndex = certificateTypeIds.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(rsaIndex, Is.GreaterThanOrEqualTo(0));
using Certificate current = Certificate.FromRawData(currentCertificates[rsaIndex]);
string[] domainNames = X509Utils.GetDomainsFromCertificate(current).ToArray();
using Certificate newCertificate = DefaultCertificateFactory.Instance
.CreateApplicationCertificate(
fixture.Config.ApplicationUri,
fixture.Config.ApplicationName,
current.Subject,
domainNames)
.CreateForRSA();
ByteString privateKey = newCertificate.Export(X509ContentType.Pfx).ToByteString();
var issuerStoreIdentifier = new CertificateStoreIdentifier(
fixture.Config.SecurityConfiguration.TrustedIssuerCertificates.StorePath!);
int issuerCountBefore;
using (ICertificateStore issuerStoreBefore = issuerStoreIdentifier.OpenStore(s_telemetry))
{
using CertificateCollection allBefore = await issuerStoreBefore.EnumerateAsync()
.ConfigureAwait(false);
issuerCountBefore = allBefore.Count;
}
using Certificate bogusIssuer = CertificateBuilder
.Create("CN=Bogus Supplied Issuer " + Guid.NewGuid().ToString("N")[..8])
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
UpdateCertificateMethodStateResult updateResult = await configNode.UpdateCertificate.OnCallAsync(
context,
configNode.UpdateCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
newCertificate.RawData.ToByteString(),
[bogusIssuer.RawData.ToByteString()],
"pfx",
privateKey,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(updateResult.ServiceResult), Is.True);
Assert.That(updateResult.ApplyChangesRequired, Is.True);
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult applyResult = await configNode.ApplyChanges.OnCallMethod2Async(
context,
configNode.ApplyChanges,
configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(applyResult), Is.True);
await configManager.DrainPendingApplyChangesAsync(CancellationToken.None).ConfigureAwait(false);
using (ICertificateStore issuerStoreAfter = issuerStoreIdentifier.OpenStore(s_telemetry))
{
using CertificateCollection allAfter = await issuerStoreAfter.EnumerateAsync()
.ConfigureAwait(false);
Assert.That(
allAfter,
Has.Count.EqualTo(issuerCountBefore),
"no supplied issuer may be staged/imported for an ApplicationCertificate-purpose group");
using CertificateCollection bogusMatches = await issuerStoreAfter
.FindByThumbprintAsync(bogusIssuer.Thumbprint).ConfigureAwait(false);
Assert.That(bogusMatches, Has.Count.EqualTo(0));
}
ArrayOf<NodeId> certificateTypeIdsAfter = default;
ArrayOf<ByteString> certificatesAfter = default;
ServiceResult getAfterResult = configNode.GetCertificates.OnCall(
context,
configNode.GetCertificates,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIdsAfter,
ref certificatesAfter);
Assert.That(ServiceResult.IsGood(getAfterResult), Is.True);
int rsaIndexAfter = certificateTypeIdsAfter.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(rsaIndexAfter, Is.GreaterThanOrEqualTo(0));
using Certificate committed = Certificate.FromRawData(certificatesAfter[rsaIndexAfter]);
Assert.That(
committed.Thumbprint,
Is.EqualTo(newCertificate.Thumbprint),
"the replacement certificate must actually have been committed");
}
finally
{
if (server != null)
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
}
public void UpdateCertificateWithInvalidPrivateKeyThrowsBadSecurityChecksFailed(string privateKeyFormat)
{
ISystemContext context = CreateAdminContext();
ByteString currentCertificate = GetCurrentRsaCertificate(context);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
currentCertificate,
[],
privateKeyFormat,
ByteString.From([1, 2, 3, 4]),
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed));
}
[Test]
public void UpdateCertificateWithUnsupportedPrivateKeyFormatThrowsBadNotSupported()
{
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
ByteString.From([1, 2, 3, 4]),
[],
"DER",
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
}
[Test]
public void UpdateCertificateNonAdminThrowsBadUserAccessDenied()
{
ISystemContext context = CreateAnonymousContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
ByteString.Empty,
[],
null,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public void UpdateCertificateWithInsecureChannelThrowsBadSecurityModeInsufficient()
{
// OPC 10000-12 §7.10.5: UpdateCertificate requires an encrypted
// SecureChannel; a Sign-only channel is reported as
// Bad_SecurityModeInsufficient (a channel-security failure),
// distinct from the Bad_UserAccessDenied Role failure.
ISystemContext context = CreateContext(
UserTokenType.UserName,
MessageSecurityMode.Sign,
ObjectIds.WellKnownRole_SecurityAdmin);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
ByteString.Empty,
[],
null,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSecurityModeInsufficient));
}
[Test]
public void UpdateCertificateWithInvalidGroupThrowsBadInvalidArgument()
{
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
new NodeId(Guid.NewGuid(), 1),
ObjectTypeIds.RsaSha256ApplicationCertificateType,
ByteString.From([1, 2, 3, 4]),
[],
null,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public async Task UpdateCertificateForApplicationCertificateGroupIgnoresSuppliedIssuerCertificatesAsync()
{
// OPC 10000-12 §7.10.5: "If the CertificateGroup Purpose is
// ApplicationCertificateType, this list is redundant ...
// therefore the Server shall ignore this list." A well-formed
// but otherwise irrelevant supplied issuer certificate must
// never be staged/imported nor cause validation to fail, even
// for a self-signed replacement certificate (which previously
// required an empty issuer list).
ISystemContext context = CreateAdminContext();
ArrayOf<NodeId> certificateTypeIds = default;
ArrayOf<ByteString> certificates = default;
ServiceResult getResult = m_configNode.GetCertificates.OnCall(
context,
m_configNode.GetCertificates,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIds,
ref certificates);
Assert.That(ServiceResult.IsGood(getResult), Is.True);
int index = certificateTypeIds.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(index, Is.GreaterThanOrEqualTo(0));
ByteString currentCertificate = certificates[index];
using Certificate fakeIssuer = CertificateBuilder.Create("CN=Fake Issuer")
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
UpdateCertificateMethodStateResult result = await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
currentCertificate,
[fakeIssuer.RawData.ToByteString()],
null,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
Assert.That(result.ApplyChangesRequired, Is.True);
}
[Test]
public async Task UpdateCertificateWithPfxPrivateKeyStagesCertificateAsync()
{
ISystemContext context = CreateAdminContext();
ByteString currentCertificate = GetCurrentRsaCertificate(context);
using var current = Certificate.FromRawData(currentCertificate);
string[] domainNames = X509Utils.GetDomainsFromCertificate(current).ToArray();
using Certificate newCertificate = DefaultCertificateFactory.Instance
.CreateApplicationCertificate(
m_fixture.Config.ApplicationUri,
m_fixture.Config.ApplicationName,
current.Subject,
domainNames)
.CreateForRSA();
ByteString privateKey = newCertificate.Export(X509ContentType.Pfx).ToByteString();
UpdateCertificateMethodStateResult result = await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
newCertificate.RawData.ToByteString(),
[],
"pfx",
privateKey,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
Assert.That(result.ApplyChangesRequired, Is.True);
}
[Test]
public async Task UpdateCertificateWithPemPrivateKeyStagesCertificateAsync()
{
ISystemContext context = CreateAdminContext();
ByteString currentCertificate = GetCurrentRsaCertificate(context);
using var current = Certificate.FromRawData(currentCertificate);
string[] domainNames = X509Utils.GetDomainsFromCertificate(current).ToArray();
using Certificate newCertificate = DefaultCertificateFactory.Instance
.CreateApplicationCertificate(
m_fixture.Config.ApplicationUri,
m_fixture.Config.ApplicationName,
current.Subject,
domainNames)
.CreateForRSA();
ByteString privateKey = PEMWriter.ExportPrivateKeyAsPEM(newCertificate).ToByteString();
UpdateCertificateMethodStateResult result = await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
newCertificate.RawData.ToByteString(),
[],
"pem",
privateKey,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
Assert.That(result.ApplyChangesRequired, Is.True);
}
[Test]
public async Task UpdateCertificateWithRegeneratedPrivateKeyStagesCertificateAsync()
{
ISystemContext context = CreateAdminContext();
ByteString currentCertificate = GetCurrentRsaCertificate(context);
using var current = Certificate.FromRawData(currentCertificate);
string[] domainNames = X509Utils.GetDomainsFromCertificate(current).ToArray();
using Certificate issuer = CertificateBuilder.Create("CN=ConfigurationNodeManager Push CA")
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
CreateSigningRequestMethodStateResult signingRequest = await m_configNode
.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
current.Subject,
true,
s_regenerateNonce,
CancellationToken.None)
.ConfigureAwait(false);
var request = new Pkcs10CertificationRequest(signingRequest.CertificateRequest.ToArray());
Assert.That(request.Verify(), Is.True);
using Certificate signedCertificate = CertificateBuilder.Create(request.Subject)
.AddExtension(new X509SubjectAltNameExtension(
m_fixture.Config.ApplicationUri,
domainNames))
.SetNotBefore(DateTime.UtcNow.Date.AddDays(-1))
.SetLifeTime(12)
.SetIssuer(issuer)
.SetRSAPublicKey(request.SubjectPublicKeyInfo)
.CreateForRSA();
UpdateCertificateMethodStateResult result = await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
signedCertificate.RawData.ToByteString(),
[issuer.RawData.ToByteString()],
null,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
Assert.That(result.ApplyChangesRequired, Is.True);
}
[Test]
public async Task ApplyChangesWithPendingCertificateUpdateReturnsGoodAsync()
{
ISystemContext context = CreateAdminContext();
await UpdateCertificateWithPfxPrivateKeyStagesCertificateAsync().ConfigureAwait(false);
m_configManager.ApplyChangesGracePeriod = TimeSpan.FromMilliseconds(-1);
ArrayOf<Variant> inputArguments = [];
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult result = await m_configNode.ApplyChanges.OnCallMethod2Async(
context,
m_configNode.ApplyChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
await m_configManager.DrainPendingApplyChangesAsync(CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True);
}
[Test]
public async Task DrainPendingApplyChangesAsyncWithCancellationTokenCancelsAsync()
{
ISystemContext context = CreateAdminContext();
await UpdateCertificateWithPfxPrivateKeyStagesCertificateAsync().ConfigureAwait(false);
m_configManager.ApplyChangesGracePeriod = TimeSpan.FromMilliseconds(250);
ArrayOf<Variant> inputArguments = [];
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult result = await m_configNode.ApplyChanges.OnCallMethod2Async(
context,
m_configNode.ApplyChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
using var cancellationTokenSource = new CancellationTokenSource();
await cancellationTokenSource.CancelAsync().ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result), Is.True);
Assert.ThrowsAsync<TaskCanceledException>(async () =>
await m_configManager
.DrainPendingApplyChangesAsync(cancellationTokenSource.Token)
.ConfigureAwait(false));
await m_configManager.DrainPendingApplyChangesAsync(CancellationToken.None)
.ConfigureAwait(false);
}
// NOTE: A test that drives UpdateCertificate + ApplyChanges through a
// full, completed rotation (i.e. awaiting DrainPendingApplyChangesAsync)
// is intentionally NOT included. ScheduleDeferredApplyChanges calls
// CertificateManager.UpdateAsync, which notifies every registered
// ITransportListener; TcpTransportListener.CertificateUpdate then
// calls CertificateManager.AcquireApplicationCertificateBySecurityPolicy,
// which AddRef()s a CertificateEntry that is never released. That is
// a genuine, pre-existing resource leak several layers below
// ConfigurationNodeManager (in Opc.Ua.Core's TcpTransportListener /
// CertificateManager), reproducible deterministically as soon as a
// real ApplyChanges rotation completes against a running TCP
// listener. It trips the assembly-wide
// Opc.Ua.Server.Tests.LeakDetectionSetup certificate-leak assertion.
// Fixing it is out of scope for this test-only change (it lives
// outside ConfigurationNodeManager.cs entirely). ApplyChanges' own
// request-building/staging logic (lines up to the deferred
// scheduling call) is still covered by the tests below and by
// UpdateCertificate's own success/failure staging tests.
[Test]
public async Task ApplyChangesWithNoPendingUpdatesReturnsBadNothingToDoAsync()
{
// OPC 10000-12 §7.10.11: ApplyChanges returns BadNothingToDo
// when no PushManagement transaction is active.
ISystemContext context = CreateAdminContext();
ArrayOf<Variant> inputArguments = [];
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult result = await m_configNode.ApplyChanges.OnCallMethod2Async(
context,
m_configNode.ApplyChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNothingToDo));
}
[Test]
public void ApplyChangesNonAdminThrowsBadUserAccessDenied()
{
ISystemContext context = CreateAnonymousContext();
ArrayOf<Variant> inputArguments = [];
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.ApplyChanges.OnCallMethod2Async(
context,
m_configNode.ApplyChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public async Task DrainPendingApplyChangesAsyncWithNoPendingTaskCompletesImmediatelyAsync()
{
await m_configManager.DrainPendingApplyChangesAsync(CancellationToken.None)
.ConfigureAwait(false);
await m_configManager.DrainPendingApplyChangesAsync()
.ConfigureAwait(false);
}
[Test]
public void ValidatePushCertificateAndIssuerChainWithNullCertificateThrowsArgumentNullException()
{
using var issuerCertificates = new CertificateCollection();
Assert.ThrowsAsync<ArgumentNullException>(async () =>
await ConfigurationNodeManager.ValidatePushCertificateAndIssuerChainAsync(
null,
issuerCertificates,
m_fixture.Config.SecurityConfiguration,
s_telemetry,
CancellationToken.None)
.ConfigureAwait(false));
}
[Test]
public void ValidatePushCertificateAndIssuerChainWithNullIssuersThrowsArgumentNullException()
{
using Certificate certificate = CertificateBuilder.Create("CN=Validation Subject")
.CreateForRSA();
Assert.ThrowsAsync<ArgumentNullException>(async () =>
await ConfigurationNodeManager.ValidatePushCertificateAndIssuerChainAsync(
certificate,
null,
m_fixture.Config.SecurityConfiguration,
s_telemetry,
CancellationToken.None)
.ConfigureAwait(false));
}
[Test]
public void ValidatePushCertificateAndIssuerChainWithNullConfigurationThrowsArgumentNullException()
{
using Certificate certificate = CertificateBuilder.Create("CN=Validation Subject")
.CreateForRSA();
using var issuerCertificates = new CertificateCollection();
Assert.ThrowsAsync<ArgumentNullException>(async () =>
await ConfigurationNodeManager.ValidatePushCertificateAndIssuerChainAsync(
certificate,
issuerCertificates,
null,
s_telemetry,
CancellationToken.None)
.ConfigureAwait(false));
}
[Test]
public void ValidatePushCertificateAndIssuerChainWithNullTelemetryThrowsArgumentNullException()
{
using Certificate certificate = CertificateBuilder.Create("CN=Validation Subject")
.CreateForRSA();
using var issuerCertificates = new CertificateCollection();
Assert.ThrowsAsync<ArgumentNullException>(async () =>
await ConfigurationNodeManager.ValidatePushCertificateAndIssuerChainAsync(
certificate,
issuerCertificates,
m_fixture.Config.SecurityConfiguration,
null,
CancellationToken.None)
.ConfigureAwait(false));
}
[Test]
public void StartAlarmMonitoringTwiceThenStopDoesNotThrow()
{
Assert.DoesNotThrow(() => m_configManager.StartAlarmMonitoring(TimeSpan.FromMilliseconds(50)));
// Second call must hit the early-return branch (timer already running).
Assert.DoesNotThrow(() => m_configManager.StartAlarmMonitoring(TimeSpan.FromMilliseconds(50)));
Assert.DoesNotThrow(() => m_configManager.StopAlarmMonitoring());
// Stopping again must be a no-op.
Assert.DoesNotThrow(m_configManager.StopAlarmMonitoring);
}
[Test]
public async Task GetNamespaceMetadataStateWithNullUriReturnsNullAsync()
{
NamespaceMetadataState result = await m_configManager
.GetNamespaceMetadataStateAsync(null)
.ConfigureAwait(false);
Assert.That(result, Is.Null);
}
[Test]
public async Task GetNamespaceMetadataStateByIndexReturnsMetadataAsync()
{
// Use the server's own application namespace URI (already registered
// in the NamespaceUris table with a stable index), since
// CreateNamespaceMetadataStateAsync does not itself register a brand
// new namespace URI in that table.
string namespaceUri = m_server.CurrentInstance.NamespaceUris.GetString(1);
NamespaceMetadataState created = await m_configManager
.CreateNamespaceMetadataStateAsync(namespaceUri)
.ConfigureAwait(false);
Assert.That(created, Is.Not.Null);
ushort namespaceIndex = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex(namespaceUri);
NamespaceMetadataState byIndex = await m_configManager
.GetNamespaceMetadataStateAsync(namespaceIndex)
.ConfigureAwait(false);
Assert.That(byIndex, Is.SameAs(created));
// Second call must hit the cache branch.
NamespaceMetadataState byIndexCached = await m_configManager
.GetNamespaceMetadataStateAsync(namespaceIndex)
.ConfigureAwait(false);
Assert.That(byIndexCached, Is.SameAs(created));
}
[Test]
public async Task GetNamespaceMetadataStateCachedReturnsSameInstanceAsync()
{
const string namespaceUri = "http://test.org/UA/CachedLookupTest";
NamespaceMetadataState created = await m_configManager
.CreateNamespaceMetadataStateAsync(namespaceUri)
.ConfigureAwait(false);
NamespaceMetadataState first = await m_configManager
.GetNamespaceMetadataStateAsync(namespaceUri)
.ConfigureAwait(false);
NamespaceMetadataState second = await m_configManager
.GetNamespaceMetadataStateAsync(namespaceUri)
.ConfigureAwait(false);
Assert.That(first, Is.SameAs(created));
Assert.That(second, Is.SameAs(created));
}
[Test]
public void BindKeyCredentialPushWithNullSubjectThrowsArgumentNullException()
{
Assert.ThrowsAsync<ArgumentNullException>(async () =>
await m_configManager.BindKeyCredentialPushAsync(null, CancellationToken.None)
.ConfigureAwait(false));
}
[Test]
public async Task BindKeyCredentialPushWithExistingFolderBindsSubjectAsync()
{
using var store = new InMemoryKeyCredentialStore();
var subject = new KeyCredentialPushSubject(store);
Assert.DoesNotThrowAsync(async () =>
await m_configManager.BindKeyCredentialPushAsync(subject, CancellationToken.None)
.ConfigureAwait(false));
NodeState node = await m_server.CurrentInstance.NodeManager
.FindNodeInAddressSpaceAsync(
KeyCredentialPushSubject.StandardConfigurationFolderNodeId,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(node, Is.TypeOf<KeyCredentialConfigurationFolderState>());
}
[Test]
public void ConstructorWithLoggerOverloadCreatesUserAndHttpsCertificateGroups()
{
IServerInternal serverInternal = m_server.CurrentInstance;
ApplicationConfiguration configuration = m_fixture.Config;
SecurityConfiguration security = configuration.SecurityConfiguration;
CertificateTrustList originalUserIssuer = security.UserIssuerCertificates;
CertificateTrustList originalTrustedUser = security.TrustedUserCertificates;
CertificateTrustList originalHttpsIssuer = security.HttpsIssuerCertificates;
CertificateTrustList originalTrustedHttps = security.TrustedHttpsCertificates;
ArrayOf<CertificateIdentifier> originalAppCertificates = security.ApplicationCertificates;
try
{
security.UserIssuerCertificates = new CertificateTrustList
{
StorePath = security.TrustedIssuerCertificates.StorePath
};
security.TrustedUserCertificates = new CertificateTrustList
{
StorePath = security.TrustedPeerCertificates.StorePath
};
security.HttpsIssuerCertificates = new CertificateTrustList
{
StorePath = security.TrustedIssuerCertificates.StorePath
};
security.TrustedHttpsCertificates = new CertificateTrustList
{
StorePath = security.TrustedPeerCertificates.StorePath
};
security.ApplicationCertificates =
[
new CertificateIdentifier
{
StorePath = security.TrustedIssuerCertificates.StorePath,
CertificateType = ObjectTypeIds.HttpsCertificateType
}
];
using var manager = new ConfigurationNodeManager(
serverInternal,
configuration,
serverInternal.Telemetry.CreateLogger<ConfigurationNodeManager>());
Assert.That(manager, Is.Not.Null);
}
finally
{
security.UserIssuerCertificates = originalUserIssuer;
security.TrustedUserCertificates = originalTrustedUser;
security.HttpsIssuerCertificates = originalHttpsIssuer;
security.TrustedHttpsCertificates = originalTrustedHttps;
security.ApplicationCertificates = originalAppCertificates;
}
}
[Test]
public void SupportsTransactionsIsExposedAndTrue()
{
// OPC 10000-12 §7.10.2: SupportsTransactions has no well-known
// singleton-instance NodeId in the standard model (see
// ConfigurationNodeManagerTests' address-space regression
// test), so its presence is verified directly here instead.
Assert.That(m_configNode.SupportsTransactions, Is.Not.Null);
Assert.That(m_configNode.SupportsTransactions.Value, Is.True);
}
[Test]
public void MaxTrustListSizeAdvertisesHonestFiniteEffectiveLimit()
{
// OPC 10000-12 §8.4.5: the reference server configures
// MaxTrustListSize = 0 (unlimited). The server must NOT advertise 0
// while enforcing a hidden cap; it advertises the honest effective
// limit — here the default resource-protection safety ceiling —
// that the TrustList handlers actually enforce.
Assert.That(m_configNode.MaxTrustListSize, Is.Not.Null);
Assert.That(m_configNode.MaxTrustListSize.Value, Is.GreaterThan(0u));
Assert.That(
m_configNode.MaxTrustListSize.Value,
Is.LessThanOrEqualTo((uint)TrustList.DefaultMaxTrustListSizeSafetyCeiling));
}
[Test]
public void DeleteCertificateNodeIsBound()
{
Assert.That(m_configNode.DeleteCertificate, Is.Not.Null);
Assert.That(m_configNode.DeleteCertificate.OnCallAsync, Is.Not.Null);
}
[Test]
public void TransactionDiagnosticsNodeAndMandatoryChildrenAreBound()
{
Assert.That(m_configNode.TransactionDiagnostics, Is.Not.Null);
Assert.That(m_configNode.TransactionDiagnostics.StartTime, Is.Not.Null);
Assert.That(m_configNode.TransactionDiagnostics.EndTime, Is.Not.Null);
Assert.That(m_configNode.TransactionDiagnostics.Result, Is.Not.Null);
Assert.That(m_configNode.TransactionDiagnostics.AffectedTrustLists, Is.Not.Null);
Assert.That(m_configNode.TransactionDiagnostics.AffectedCertificateGroups, Is.Not.Null);
Assert.That(m_configNode.TransactionDiagnostics.Errors, Is.Not.Null);
}
[Test]
public async Task TransactionDiagnosticsReportBadOutOfServiceBeforeAnyTransactionAsync()
{
// OPC 10000-12 §7.10.17: "If no transaction has started the values
// of all Variables have a status of Bad_OutOfService." A pristine
// coordinator is swapped in so the shared fixture's transaction
// history does not interfere.
var fresh = new PushConfigurationTransactionCoordinator(s_telemetry);
IPushConfigurationTransactionCoordinator original = SwapCoordinator(m_configManager, fresh);
try
{
// CancelChanges returns BadNothingToDo on the pristine
// coordinator but still refreshes TransactionDiagnostics from
// its (None) snapshot.
await InvokeCancelChangesAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(m_configNode.TransactionDiagnostics.StartTime.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadOutOfService));
Assert.That(m_configNode.TransactionDiagnostics.EndTime.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadOutOfService));
Assert.That(m_configNode.TransactionDiagnostics.Result.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadOutOfService));
Assert.That(m_configNode.TransactionDiagnostics.AffectedTrustLists.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadOutOfService));
Assert.That(m_configNode.TransactionDiagnostics.AffectedCertificateGroups.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadOutOfService));
Assert.That(m_configNode.TransactionDiagnostics.Errors.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadOutOfService));
});
}
finally
{
SwapCoordinator(m_configManager, original);
}
}
[Test]
public async Task TransactionDiagnosticsReportBadInvalidStateWhileTransactionActiveAsync()
{
// OPC 10000-12 §7.10.17: "It has a status of Bad_InvalidState if a
// transaction has started but not completed." Staging a certificate
// change starts the transaction and refreshes the diagnostics node
// immediately (not only at ApplyChanges).
await UpdateCertificateWithPfxPrivateKeyStagesCertificateAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(m_configNode.TransactionDiagnostics.Result.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.BadInvalidState));
Assert.That(m_configNode.TransactionDiagnostics.StartTime.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.Good));
// §7.10.17: EndTime is DateTime.MinValue until the transaction
// completes. The UA DateTime encoding clamps MinValue to the
// 1601 epoch, so compare against the same round-tripped value.
Assert.That((DateTime)m_configNode.TransactionDiagnostics.EndTime.Value,
Is.EqualTo((DateTime)(DateTimeUtc)DateTime.MinValue));
});
// TearDown cancels the still-active transaction.
}
[Test]
public async Task TransactionDiagnosticsReportGoodResultAfterCompletedTransactionAsync()
{
// OPC 10000-12 §7.10.17: once the transaction completes the Result
// status is Good and its value is the ApplyChanges StatusCode.
var fresh = new PushConfigurationTransactionCoordinator(s_telemetry);
IPushConfigurationTransactionCoordinator original = SwapCoordinator(m_configManager, fresh);
try
{
var sessionId = new NodeId(Guid.NewGuid(), 1);
var group = new NodeId(Guid.NewGuid(), 1);
fresh.Stage(sessionId, new PushConfigurationOperation
{
AffectedCertificateGroup = group,
CommitAsync = _ => Task.CompletedTask
});
await fresh.ApplyChangesAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
// Refresh the node from the now-completed snapshot (a
// BadNothingToDo CancelChanges still refreshes diagnostics).
await InvokeCancelChangesAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(m_configNode.TransactionDiagnostics.Result.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.Good));
Assert.That(m_configNode.TransactionDiagnostics.Result.Value,
Is.EqualTo((StatusCode)StatusCodes.Good));
Assert.That((DateTime)m_configNode.TransactionDiagnostics.EndTime.Value,
Is.GreaterThan(DateTime.MinValue));
Assert.That(m_configNode.TransactionDiagnostics.EndTime.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.Good));
Assert.That(m_configNode.TransactionDiagnostics.AffectedCertificateGroups.Value.Contains(group),
Is.True);
});
}
finally
{
SwapCoordinator(m_configManager, original);
}
}
private async Task InvokeCancelChangesAsync()
{
await m_configNode.CancelChanges.OnCallMethod2Async(
CreateAdminContext(),
m_configNode.CancelChanges,
m_configNode.NodeId,
ArrayOf<Variant>.Empty,
new System.Collections.Generic.List<Variant>(),
CancellationToken.None).ConfigureAwait(false);
}
private static IPushConfigurationTransactionCoordinator SwapCoordinator(
ConfigurationNodeManager manager,
IPushConfigurationTransactionCoordinator coordinator)
{
FieldInfo field = typeof(ConfigurationNodeManager).GetField(
"m_coordinator", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Field m_coordinator not found.");
var previous = (IPushConfigurationTransactionCoordinator)field.GetValue(manager)!;
field.SetValue(manager, coordinator);
return previous;
}
private static IPushConfigurationTransactionCoordinator GetCoordinatorField(
ConfigurationNodeManager manager)
{
FieldInfo field = typeof(ConfigurationNodeManager).GetField(
"m_coordinator", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Field m_coordinator not found.");
return (IPushConfigurationTransactionCoordinator)field.GetValue(manager)!;
}
[Test]
public void EachConfigurationNodeManagerOwnsADistinctDefaultCoordinator()
{
// OPC 10000-12 §§7.10.2-7.10.11: the coordinator is per-server
// state, so two ConfigurationNodeManagers (one per server, e.g.
// two servers built from one DI container) that are not given an
// injected coordinator must each own a distinct default instance.
IServerInternal serverInternal = m_server.CurrentInstance;
using var managerA = new ConfigurationNodeManager(
serverInternal,
m_fixture.Config,
serverInternal.Telemetry.CreateLogger<ConfigurationNodeManager>(),
timeProvider: null,
coordinator: null,
pendingKeyStore: null);
using var managerB = new ConfigurationNodeManager(
serverInternal,
m_fixture.Config,
serverInternal.Telemetry.CreateLogger<ConfigurationNodeManager>(),
timeProvider: null,
coordinator: null,
pendingKeyStore: null);
IPushConfigurationTransactionCoordinator coordinatorA = GetCoordinatorField(managerA);
IPushConfigurationTransactionCoordinator coordinatorB = GetCoordinatorField(managerB);
Assert.That(coordinatorA, Is.Not.Null);
Assert.That(coordinatorB, Is.Not.Null);
Assert.That(
coordinatorA,
Is.Not.SameAs(coordinatorB),
"each server's ConfigurationNodeManager must own a distinct default coordinator");
}
[Test]
public void AnInjectedCoordinatorIsSharedAcrossConfigurationNodeManagers()
{
// Custom injection is preserved: an explicitly supplied coordinator
// is used as-is (the direct-construction fallback path).
IServerInternal serverInternal = m_server.CurrentInstance;
var shared = new PushConfigurationTransactionCoordinator(serverInternal.Telemetry);
using var managerA = new ConfigurationNodeManager(
serverInternal,
m_fixture.Config,
serverInternal.Telemetry.CreateLogger<ConfigurationNodeManager>(),
timeProvider: null,
coordinator: shared,
pendingKeyStore: null);
using var managerB = new ConfigurationNodeManager(
serverInternal,
m_fixture.Config,
serverInternal.Telemetry.CreateLogger<ConfigurationNodeManager>(),
timeProvider: null,
coordinator: shared,
pendingKeyStore: null);
Assert.That(GetCoordinatorField(managerA), Is.SameAs(shared));
Assert.That(GetCoordinatorField(managerB), Is.SameAs(shared));
}
[Test]
public void DeleteCertificateWithInvalidGroupThrowsBadInvalidArgument()
{
// The "slot already empty" rejection path is covered end-to-end
// by the isolated fixture in
// DeleteCertificateOnAlreadyEmptySlotReturnsBadInvalidStateAsync
// (and, for the create-on-the-now-empty-slot half,
// CreateSelfSignedCertificateOnSlotEmptiedByDeleteCertificateSucceedsAsync).
// This test instead exercises the invalid-group rejection,
// shared with VerifyGroupAndTypeId's other callers.
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.DeleteCertificate.OnCallAsync(
context,
m_configNode.DeleteCertificate,
m_configNode.NodeId,
new NodeId(Guid.NewGuid(), 1),
ObjectTypeIds.RsaSha256ApplicationCertificateType,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public void DeleteCertificateNonAdminThrowsBadUserAccessDenied()
{
ISystemContext context = CreateAnonymousContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.DeleteCertificate.OnCallAsync(
context,
m_configNode.DeleteCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public async Task DeleteCertificateOnAlreadyEmptySlotReturnsBadInvalidStateAsync()
{
// OPC 10000-12 §7.10.7: "If no Certificate is assigned to the
// CertificateType slot then a Bad_InvalidState error is
// returned." Because the RSA certificate is endpoint-referenced it
// cannot be deleted for real (§7.10.7), so the empty-slot state is
// reached by staging a first DeleteCertificate (which nets the
// slot empty via IsSlotOccupiedAsync); a second DeleteCertificate
// must then observe the netted-empty slot. The transaction is
// cancelled so the shared server certificate is untouched.
// Isolated fixture keeps the staged transaction off m_configManager.
var fixture = new ServerFixture<StandardServer>(t => new ReferenceServer(t));
StandardServer server = null;
try
{
server = await fixture.StartAsync().ConfigureAwait(false);
NodeState node = await server.CurrentInstance.NodeManager
.FindNodeInAddressSpaceAsync(ObjectIds.ServerConfiguration)
.ConfigureAwait(false);
var configNode = node as ServerConfigurationState;
Assert.That(configNode, Is.Not.Null);
var configManager = server.CurrentInstance.ConfigurationNodeManager as ConfigurationNodeManager;
Assert.That(configManager, Is.Not.Null);
ISystemContext context = CreateAdminContext();
DeleteCertificateMethodStateResult firstDeleteResult = await configNode
.DeleteCertificate.OnCallAsync(
context,
configNode.DeleteCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(firstDeleteResult.ServiceResult), Is.True);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await configNode.DeleteCertificate.OnCallAsync(
context,
configNode.DeleteCertificate,
configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
await configNode.CancelChanges.OnCallMethod2Async(
context,
configNode.CancelChanges,
configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
}
finally
{
if (server != null)
{
await fixture.StopAsync().ConfigureAwait(false);
}
}
}
[Test]
public async Task DeleteCertificateForEveryTypeInOneTransactionRejectsTheLastOneAsync()
{
// OPC 10000-12 §7.10.7: staging DeleteCertificate for every
// certificate type in the same transaction must not be able to
// leave the server with zero occupied application-certificate
// slots once ApplyChanges commits them all together, even
// though each individual staging request only ever sees the
// (unmodified until commit) live registry. The shared
// fixture's DefaultApplicationGroup has at least 3 occupied
// types (RSA + two ECC curves; see
// ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates),
// so deleting all but the last must succeed individually while
// the last one must be rejected.
ISystemContext context = CreateAdminContext();
ArrayOf<NodeId> certificateTypeIds = default;
ArrayOf<ByteString> certificates = default;
ServiceResult getResult = m_configNode.GetCertificates.OnCall(
context,
m_configNode.GetCertificates,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIds,
ref certificates);
Assert.That(ServiceResult.IsGood(getResult), Is.True);
var types = certificateTypeIds.ToList();
Assert.That(
types,
Has.Count.GreaterThanOrEqualTo(3),
"Test requires the fixture's default RSA + ECC certificate types.");
try
{
for (int i = 0; i < types.Count - 1; i++)
{
DeleteCertificateMethodStateResult deleteResult = await m_configNode
.DeleteCertificate.OnCallAsync(
context,
m_configNode.DeleteCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
types[i],
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(
ServiceResult.IsGood(deleteResult.ServiceResult),
Is.True,
$"Deleting type #{i} should succeed while another type remains occupied.");
}
NodeId lastType = types[types.Count - 1];
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.DeleteCertificate.OnCallAsync(
context,
m_configNode.DeleteCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
lastType,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
}
finally
{
// Discard every staged delete without applying it so the
// shared fixture's real certificate stores remain
// untouched for the other tests in this file.
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
await m_configNode.CancelChanges.OnCallMethod2Async(
context,
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
}
}
[Test]
public async Task ApplyCertificateSlotChangeSelfCompensatesWhenAddFailsAfterDeleteSucceedsAsync()
{
// PushConfigurationTransactionCoordinator.ApplyChangesAsync only
// reverse-compensates operations that already committed in
// full; the operation whose own CommitAsync throws is excluded
// from that loop. ApplyCertificateSlotChangeAsync (the
// primitive shared by every staged certificate operation's
// commit) must therefore be self-compensating: when the
// previous certificate has already been deleted and the new
// one then fails to persist, it must restore the previous
// certificate itself before the exception propagates, so the
// slot is never left empty despite ApplyChanges reporting a
// failure. A certificate whose thumbprint is already present
// in the store is used to make the add deterministically fail
// (DirectoryCertificateStore.AddAsync throws ArgumentException
// for a duplicate thumbprint).
string tempPki = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"cnm-selfcompensate",
Guid.NewGuid().ToString("N")[..8]);
try
{
var storeIdentifier = new CertificateStoreIdentifier(
tempPki,
CertificateStoreType.Directory,
noPrivateKeys: false);
using Certificate oldCertificate = CertificateBuilder
.Create("CN=SelfCompensate Old " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
using Certificate blockingCertificate = CertificateBuilder
.Create("CN=SelfCompensate Blocker " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
using (ICertificateStore seedStore = storeIdentifier.OpenStore(s_telemetry))
{
await seedStore.AddAsync(oldCertificate, ct: CancellationToken.None).ConfigureAwait(false);
// Already present with the same thumbprint before the
// call under test, so re-adding it below deterministically fails.
await seedStore.AddAsync(blockingCertificate, ct: CancellationToken.None).ConfigureAwait(false);
}
var existingCertIdentifier = new CertificateIdentifier
{
StorePath = tempPki,
StoreType = CertificateStoreType.Directory,
CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType,
Thumbprint = oldCertificate.Thumbprint
};
ArgumentException caught = Assert.ThrowsAsync<ArgumentException>(async () =>
await InvokeApplyCertificateSlotChangeAsync(
m_configManager,
existingCertIdentifier,
oldCertificate.Thumbprint,
blockingCertificate,
oldCertificate)
.ConfigureAwait(false));
Assert.That(caught, Is.Not.Null, "the duplicate-thumbprint add must fail");
using ICertificateStore verifyStore = storeIdentifier.OpenStore(s_telemetry);
using CertificateCollection oldRestored = await verifyStore
.FindByThumbprintAsync(oldCertificate.Thumbprint)
.ConfigureAwait(false);
Assert.That(
oldRestored,
Has.Count.EqualTo(1),
"self-compensation must restore the deleted certificate after the replacement failed");
using CertificateCollection allCerts = await verifyStore.EnumerateAsync().ConfigureAwait(false);
Assert.That(
allCerts,
Has.Count.EqualTo(2),
"the store must end up exactly where it started: old certificate restored, blocker untouched");
}
finally
{
if (Directory.Exists(tempPki))
{
Directory.Delete(tempPki, true);
}
}
}
/// <summary>
/// Invokes the private <c>ApplyCertificateSlotChangeAsync</c>
/// primitive directly so its self-compensation behavior can be
/// verified without needing to reproduce an entire failing
/// <c>UpdateCertificate</c> request end-to-end. The
/// <c>certificateGroup</c> argument is never dereferenced by the
/// method for this test (no issuer chain is supplied), so reusing
/// the shared fixture's own first configured group is sufficient.
/// </summary>
private static async Task InvokeApplyCertificateSlotChangeAsync(
ConfigurationNodeManager manager,
CertificateIdentifier existingCertIdentifier,
string removeThumbprint,
Certificate addCertificateWithKey,
Certificate removedCertificateBackup)
{
Type managerType = typeof(ConfigurationNodeManager);
FieldInfo groupsField = managerType.GetField(
"m_certificateGroups",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Field m_certificateGroups not found.");
var groups = (System.Collections.IList)groupsField.GetValue(manager)!;
object certificateGroup = groups[0]!;
MethodInfo method = managerType.GetMethod(
"ApplyCertificateSlotChangeAsync",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Method ApplyCertificateSlotChangeAsync not found.");
var task = (Task)method.Invoke(
manager,
[
certificateGroup,
existingCertIdentifier,
removeThumbprint,
addCertificateWithKey,
null,
CancellationToken.None,
removedCertificateBackup
])!;
await task.ConfigureAwait(false);
}
/// <summary>
/// Invokes the private <c>ApplyCertificateSlotChangeAsync</c>
/// primitive directly with a non-null issuer chain (and no
/// application-certificate mutation), returning the thumbprints
/// it reports as newly added, so the issuer-tracking behavior can
/// be verified without reproducing an entire
/// <c>UpdateCertificate</c> request end-to-end.
/// </summary>
private static Task<ArrayOf<string>> InvokeApplyCertificateSlotChangeForIssuersAsync(
ConfigurationNodeManager manager,
object certificateGroup,
CertificateIdentifier existingCertIdentifier,
CertificateCollection addIssuerChain)
{
MethodInfo method = typeof(ConfigurationNodeManager).GetMethod(
"ApplyCertificateSlotChangeAsync",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Method ApplyCertificateSlotChangeAsync not found.");
return (Task<ArrayOf<string>>)method.Invoke(
manager,
[
certificateGroup,
existingCertIdentifier,
null,
null,
addIssuerChain,
CancellationToken.None,
null
])!;
}
/// <summary>
/// Invokes the private <c>RemoveIssuerCertificatesAsync</c> helper
/// directly, exactly as <c>UpdateCertificate</c>'s
/// <c>RollbackAsync</c> would when compensating a completed
/// issuer import.
/// </summary>
private static async Task InvokeRemoveIssuerCertificatesAsync(
ConfigurationNodeManager manager,
object certificateGroup,
ArrayOf<string> thumbprints)
{
MethodInfo method = typeof(ConfigurationNodeManager).GetMethod(
"RemoveIssuerCertificatesAsync",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Method RemoveIssuerCertificatesAsync not found.");
var task = (Task)method.Invoke(manager, [certificateGroup, thumbprints, CancellationToken.None])!;
await task.ConfigureAwait(false);
}
[Test]
public async Task ApplyCertificateSlotChangeTracksNewlyAddedIssuersAndPreservesPreExistingOnesAsync()
{
// Issue: UpdateCertificate's commit imports the entire staged
// issuer chain into the group's issuer store without
// recording which of those issuers were already present.
// ApplyCertificateSlotChangeAsync must report exactly the
// thumbprints it newly added (excluding any pre-existing
// issuer that also happened to be part of the submitted
// chain), so a later rollback/self-compensation - exercised
// end-to-end in
// UpdateCertificateRollbackAfterLaterOperationFailureRestoresAppCertAndRemovesNewIssuerAsync -
// removes only the issuers it actually introduced.
Type managerType = typeof(ConfigurationNodeManager);
FieldInfo groupsField = managerType.GetField(
"m_certificateGroups",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Field m_certificateGroups not found.");
var groups = (System.Collections.IList)groupsField.GetValue(m_configManager)!;
object certificateGroup = groups[0]!;
PropertyInfo appCertificatesProperty = certificateGroup.GetType().GetProperty("ApplicationCertificates")
?? throw new InvalidOperationException("Property ApplicationCertificates not found.");
var applicationCertificates = (ArrayOf<CertificateIdentifier>)appCertificatesProperty
.GetValue(certificateGroup)!;
// removeThumbprint/addCertificateWithKey are both null for
// this call (see InvokeApplyCertificateSlotChangeForIssuersAsync),
// so this identifier's application store is never mutated;
// reusing the real, already-open RSA identifier is safe.
CertificateIdentifier existingCertIdentifier = applicationCertificates.ToList()
.First(c => c.CertificateType == ObjectTypeIds.RsaSha256ApplicationCertificateType);
var issuerStoreIdentifier = new CertificateStoreIdentifier(
m_fixture.Config.SecurityConfiguration.TrustedIssuerCertificates.StorePath!);
using Certificate preExistingIssuer = CertificateBuilder
.Create("CN=PreExisting Issuer " + Guid.NewGuid().ToString("N")[..8])
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
using Certificate newIssuer = CertificateBuilder
.Create("CN=Newly Added Issuer " + Guid.NewGuid().ToString("N")[..8])
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
try
{
using (ICertificateStore seedStore = issuerStoreIdentifier.OpenStore(s_telemetry))
{
// Simulates an issuer already trusted by an earlier,
// already-committed operation. newIssuer is not added
// here, so it must be reported as newly added below.
await seedStore.AddAsync(preExistingIssuer, ct: CancellationToken.None).ConfigureAwait(false);
}
using var addIssuerChain = new CertificateCollection { preExistingIssuer, newIssuer };
ArrayOf<string> newlyAdded = await InvokeApplyCertificateSlotChangeForIssuersAsync(
m_configManager,
certificateGroup,
existingCertIdentifier,
addIssuerChain).ConfigureAwait(false);
Assert.That(
newlyAdded.ToList(),
Is.EquivalentTo(new[] { newIssuer.Thumbprint }),
"only the issuer that was not already present must be reported as newly added");
using (ICertificateStore verifyStore = issuerStoreIdentifier.OpenStore(s_telemetry))
{
using CertificateCollection preExistingMatches = await verifyStore
.FindByThumbprintAsync(preExistingIssuer.Thumbprint).ConfigureAwait(false);
Assert.That(preExistingMatches, Has.Count.EqualTo(1), "the pre-existing issuer must remain");
using CertificateCollection newMatches = await verifyStore
.FindByThumbprintAsync(newIssuer.Thumbprint).ConfigureAwait(false);
Assert.That(newMatches, Has.Count.EqualTo(1), "the newly added issuer must have been imported");
}
// Compensate exactly as UpdateCertificateAsync's
// RollbackAsync would, using the thumbprints reported
// above, and verify only the newly added issuer is
// removed.
await InvokeRemoveIssuerCertificatesAsync(m_configManager, certificateGroup, newlyAdded)
.ConfigureAwait(false);
using ICertificateStore finalStore = issuerStoreIdentifier.OpenStore(s_telemetry);
using CertificateCollection preExistingAfterRemoval = await finalStore
.FindByThumbprintAsync(preExistingIssuer.Thumbprint).ConfigureAwait(false);
Assert.That(
preExistingAfterRemoval,
Has.Count.EqualTo(1),
"removing the newly added issuers must never remove a pre-existing issuer");
using CertificateCollection newAfterRemoval = await finalStore
.FindByThumbprintAsync(newIssuer.Thumbprint).ConfigureAwait(false);
Assert.That(
newAfterRemoval,
Has.Count.EqualTo(0),
"the newly added issuer must be removed once rolled back");
}
finally
{
using ICertificateStore cleanupStore = issuerStoreIdentifier.OpenStore(s_telemetry);
await cleanupStore.DeleteAsync(preExistingIssuer.Thumbprint).ConfigureAwait(false);
await cleanupStore.DeleteAsync(newIssuer.Thumbprint).ConfigureAwait(false);
}
}
/// <summary>
/// Invokes the private <c>ApplyCertificateSlotChangeAsync</c>
/// primitive directly with every parameter populated (an
/// application-certificate slot swap plus an issuer-chain
/// import), so the self-compensation for an issuer-import
/// failure that follows a successful application-certificate
/// swap can be verified without reproducing an entire failing
/// <c>UpdateCertificate</c> request end-to-end.
/// </summary>
private static Task<ArrayOf<string>> InvokeApplyCertificateSlotChangeFullAsync(
ConfigurationNodeManager manager,
object certificateGroup,
CertificateIdentifier existingCertIdentifier,
string removeThumbprint,
Certificate addCertificateWithKey,
CertificateCollection addIssuerChain,
Certificate removedCertificateBackup)
{
MethodInfo method = typeof(ConfigurationNodeManager).GetMethod(
"ApplyCertificateSlotChangeAsync",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Method ApplyCertificateSlotChangeAsync not found.");
return (Task<ArrayOf<string>>)method.Invoke(
manager,
[
certificateGroup,
existingCertIdentifier,
removeThumbprint,
addCertificateWithKey,
addIssuerChain,
CancellationToken.None,
removedCertificateBackup
])!;
}
[Test]
public async Task ApplyCertificateSlotChangeSelfCompensatesWhenIssuerImportFailsAfterAppCertSwapAsync()
{
// Issue: once ApplyCertificateSlotChangeAsync has fully
// swapped the application-certificate slot (the previous
// certificate deleted and the new one added), a subsequent
// failure importing that certificate's issuer chain must not
// leave the new application certificate live alongside a
// half-imported issuer chain. The method must self-compensate
// before propagating: restore the previous application
// certificate, remove exactly the issuer certificates the
// import loop had newly added so far, and preserve any
// issuer that was already present. A directory is
// pre-created at the exact file path the store would write
// the poisoned issuer certificate to, so that import
// deterministically fails with UnauthorizedAccessException -
// not the ArgumentException the loop already tolerates for a
// duplicate thumbprint.
string tempAppPki = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"cnm-selfcompensate-issuer-app",
Guid.NewGuid().ToString("N")[..8]);
string tempIssuerPki = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"cnm-selfcompensate-issuer-store",
Guid.NewGuid().ToString("N")[..8]);
try
{
var appStoreIdentifier = new CertificateStoreIdentifier(
tempAppPki,
CertificateStoreType.Directory,
noPrivateKeys: false);
using Certificate oldCertificate = CertificateBuilder
.Create("CN=IssuerSelfCompensate Old " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
using Certificate newCertificate = CertificateBuilder
.Create("CN=IssuerSelfCompensate New " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
using (ICertificateStore seedAppStore = appStoreIdentifier.OpenStore(s_telemetry))
{
await seedAppStore.AddAsync(oldCertificate, ct: CancellationToken.None).ConfigureAwait(false);
}
var existingCertIdentifier = new CertificateIdentifier
{
StorePath = tempAppPki,
StoreType = CertificateStoreType.Directory,
CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType,
Thumbprint = oldCertificate.Thumbprint
};
var issuerStoreIdentifier = new CertificateStoreIdentifier(
tempIssuerPki,
CertificateStoreType.Directory,
noPrivateKeys: true);
using Certificate preExistingIssuer = CertificateBuilder
.Create("CN=IssuerSelfCompensate PreExisting " + Guid.NewGuid().ToString("N")[..8])
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
using Certificate newIssuer = CertificateBuilder
.Create("CN=IssuerSelfCompensate NewlyAdded " + Guid.NewGuid().ToString("N")[..8])
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
using Certificate poisonedIssuer = CertificateBuilder
.Create("CN=IssuerSelfCompensate Poisoned " + Guid.NewGuid().ToString("N")[..8])
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
using (ICertificateStore seedIssuerStore = issuerStoreIdentifier.OpenStore(s_telemetry))
{
// Simulates an issuer already trusted by an earlier,
// already-committed operation; must survive self-compensation.
await seedIssuerStore.AddAsync(preExistingIssuer, ct: CancellationToken.None)
.ConfigureAwait(false);
}
MethodInfo getFileNameMethod = typeof(DirectoryCertificateStore).GetMethod(
"GetFileName",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("Method GetFileName not found.");
var poisonedFileName = (string)getFileNameMethod.Invoke(null, [poisonedIssuer])!;
string poisonedFilePath = Path.Combine(tempIssuerPki, "certs", poisonedFileName + ".der");
Directory.CreateDirectory(poisonedFilePath);
// Reuse the shared fixture's own first configured group
// solely for its NodeId (used only for logging); its
// IssuerStore is swapped, via reflection, for this test's
// isolated issuer store below so the fixture's real
// trusted-issuer store is never touched, and restored in
// the inner finally.
Type managerType = typeof(ConfigurationNodeManager);
FieldInfo groupsField = managerType.GetField(
"m_certificateGroups",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Field m_certificateGroups not found.");
var groups = (System.Collections.IList)groupsField.GetValue(m_configManager)!;
object certificateGroup = groups[0]!;
PropertyInfo issuerStoreProperty = certificateGroup.GetType().GetProperty("IssuerStore")
?? throw new InvalidOperationException("Property IssuerStore not found.");
object originalIssuerStore = issuerStoreProperty.GetValue(certificateGroup)!;
issuerStoreProperty.SetValue(certificateGroup, issuerStoreIdentifier);
try
{
using var addIssuerChain = new CertificateCollection
{
preExistingIssuer,
newIssuer,
poisonedIssuer
};
UnauthorizedAccessException caught = Assert.ThrowsAsync<UnauthorizedAccessException>(
async () => await InvokeApplyCertificateSlotChangeFullAsync(
m_configManager,
certificateGroup,
existingCertIdentifier,
oldCertificate.Thumbprint,
newCertificate,
addIssuerChain,
oldCertificate)
.ConfigureAwait(false));
Assert.That(caught, Is.Not.Null, "the poisoned issuer import must fail");
using ICertificateStore verifyAppStore = appStoreIdentifier.OpenStore(s_telemetry);
using CertificateCollection oldRestored = await verifyAppStore
.FindByThumbprintAsync(oldCertificate.Thumbprint)
.ConfigureAwait(false);
Assert.That(
oldRestored,
Has.Count.EqualTo(1),
"self-compensation must restore the previous application certificate " +
"after the issuer import failed");
using CertificateCollection newRemoved = await verifyAppStore
.FindByThumbprintAsync(newCertificate.Thumbprint)
.ConfigureAwait(false);
Assert.That(
newRemoved,
Has.Count.EqualTo(0),
"self-compensation must remove the new application certificate already " +
"swapped in before the issuer import failed");
using ICertificateStore verifyIssuerStore = issuerStoreIdentifier.OpenStore(s_telemetry);
using CertificateCollection preExistingAfter = await verifyIssuerStore
.FindByThumbprintAsync(preExistingIssuer.Thumbprint)
.ConfigureAwait(false);
Assert.That(
preExistingAfter,
Has.Count.EqualTo(1),
"self-compensation must never remove a pre-existing issuer certificate");
using CertificateCollection newIssuerAfter = await verifyIssuerStore
.FindByThumbprintAsync(newIssuer.Thumbprint)
.ConfigureAwait(false);
Assert.That(
newIssuerAfter,
Has.Count.EqualTo(0),
"self-compensation must remove exactly the issuer certificate this import " +
"loop had newly added before the poisoned issuer failed");
using CertificateCollection poisonedIssuerAfter = await verifyIssuerStore
.FindByThumbprintAsync(poisonedIssuer.Thumbprint)
.ConfigureAwait(false);
Assert.That(
poisonedIssuerAfter,
Has.Count.EqualTo(0),
"the poisoned issuer certificate itself was never successfully imported");
}
finally
{
issuerStoreProperty.SetValue(certificateGroup, originalIssuerStore);
}
}
finally
{
if (Directory.Exists(tempAppPki))
{
Directory.Delete(tempAppPki, true);
}
if (Directory.Exists(tempIssuerPki))
{
Directory.Delete(tempIssuerPki, true);
}
}
}
[Test]
public async Task UpdateCertificateRollbackAfterLaterOperationFailureRestoresAppCertAndRemovesNewIssuerAsync()
{
// Issue: UpdateCertificate's commit imports the staged issuer
// chain into the group's issuer store, but its RollbackAsync
// only ever restored the application certificate. Any issuer
// newly imported by a successful UpdateCertificate therefore
// stayed behind forever once a LATER operation in the same
// transaction failed to commit and the coordinator reverse-
// compensated this already-successful UpdateCertificate.
// Verify that after such a later-operation failure, both the
// application certificate AND the issuer store are fully
// restored.
ISystemContext context = CreateAdminContext();
ByteString originalCertificateBytes = GetCurrentRsaCertificate(context);
using Certificate original = Certificate.FromRawData(originalCertificateBytes);
string[] domainNames = X509Utils.GetDomainsFromCertificate(original).ToArray();
using Certificate issuerCa = CertificateBuilder
.Create("CN=UpdateRollback Issuer CA " + Guid.NewGuid().ToString("N")[..8])
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
using Certificate newCertificate = CertificateBuilder.Create(original.Subject)
.AddExtension(new global::Opc.Ua.Security.Certificates.X509SubjectAltNameExtension(
m_fixture.Config.ApplicationUri,
domainNames))
.SetIssuer(issuerCa)
.SetRSAKeySize(2048)
.CreateForRSA();
ByteString privateKey = newCertificate.Export(X509ContentType.Pfx).ToByteString();
var issuerStoreIdentifier = new CertificateStoreIdentifier(
m_fixture.Config.SecurityConfiguration.TrustedIssuerCertificates.StorePath!);
// Guard: the freshly generated CA must not already be
// trusted, so its later removal is a meaningful assertion.
using (ICertificateStore issuerStoreBefore = issuerStoreIdentifier.OpenStore(s_telemetry))
{
using CertificateCollection beforeMatches = await issuerStoreBefore
.FindByThumbprintAsync(issuerCa.Thumbprint).ConfigureAwait(false);
Assert.That(beforeMatches, Has.Count.EqualTo(0), "the fresh issuer CA must not already be trusted");
}
try
{
UpdateCertificateMethodStateResult updateResult = await m_configNode.UpdateCertificate.OnCallAsync(
context,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
newCertificate.RawData.ToByteString(),
[issuerCa.RawData.ToByteString()],
"pfx",
privateKey,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(updateResult.ServiceResult), Is.True, "UpdateCertificate must stage");
// Inject a second staged operation - for an unrelated
// slot (default/null Affected* so it cannot supersede the
// UpdateCertificate operation above) - that
// deterministically fails to commit, simulating a later
// PushManagement request in the same transaction that
// fails after this UpdateCertificate has already
// committed.
FieldInfo coordinatorField = typeof(ConfigurationNodeManager).GetField(
"m_coordinator",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Field m_coordinator not found.");
var coordinator = (IPushConfigurationTransactionCoordinator)coordinatorField
.GetValue(m_configManager)!;
coordinator.Stage(NodeId.Null, new PushConfigurationOperation
{
CommitAsync = _ => throw new InvalidOperationException("Induced failure for test.")
});
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult applyResult = await m_configNode.ApplyChanges.OnCallMethod2Async(
context,
m_configNode.ApplyChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(
ServiceResult.IsGood(applyResult),
Is.False,
"the induced later-operation failure must fail ApplyChanges");
ByteString restoredCertificateBytes = GetCurrentRsaCertificate(context);
using Certificate restored = Certificate.FromRawData(restoredCertificateBytes);
Assert.That(
restored.Thumbprint,
Is.EqualTo(original.Thumbprint),
"the application certificate must be fully restored after the later operation's failure");
using ICertificateStore issuerStoreAfter = issuerStoreIdentifier.OpenStore(s_telemetry);
using CertificateCollection afterMatches = await issuerStoreAfter
.FindByThumbprintAsync(issuerCa.Thumbprint).ConfigureAwait(false);
Assert.That(
afterMatches,
Has.Count.EqualTo(0),
"the newly imported issuer certificate must be removed once the transaction rolls back");
}
finally
{
// Defensive: ApplyChanges already resolves the
// transaction (successfully or not), so this is a no-op
// on the expected path; it only guards against the
// transaction somehow being left active if an assertion
// above fails first.
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
await m_configNode.CancelChanges.OnCallMethod2Async(
context,
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
using ICertificateStore cleanupStore = issuerStoreIdentifier.OpenStore(s_telemetry);
await cleanupStore.DeleteAsync(issuerCa.Thumbprint).ConfigureAwait(false);
}
}
[Test]
public async Task ConcurrentApplyChangesDoesNotDropTheSuccessfulCommitsRotationBookkeepingAsync()
{
// Bug: the rotation collector ApplyChanges hands to
// RegisterPendingRotation used to be a single field shared by
// every call. PushConfigurationTransactionCoordinator.ApplyChangesAsync
// keeps the transaction owned by the committing Session for
// the whole duration of the commit -- including while the
// staged operation's CommitAsync is still awaiting -- so a
// second/duplicate ApplyChanges call that races in while the
// first call's (slower) commit is still running always
// observes BadInvalidState immediately, without disturbing the
// first call's in-flight commit. But with a single shared
// collector the second call would still drain and dispose
// whatever the first call's still-in-flight, about-to-succeed
// commit had ALREADY registered, silently dropping that
// commit's SecureChannel-renegotiation bookkeeping. Correlating
// the collector per ApplyChanges call (via the ambient async
// call chain) must prevent that: the second call's own
// collector stays empty, and the first call's rotation
// survives to be scheduled for deferred apply.
//
// A dedicated coordinator (and ConfigurationNodeManager
// instance) isolates this test's staged operation from every
// other test's PushManagement transaction, while still
// reusing the shared fixture's real, running server so the
// deferred apply's certificate-manager refresh has a valid
// target (mirrors ApplyChangesWithPendingCertificateUpdateReturnsGoodAsync,
// which already proves that path is safe to drain here).
IServerInternal serverInternal = m_server.CurrentInstance;
var coordinator = new PushConfigurationTransactionCoordinator(serverInternal.Telemetry);
using var manager = new ConfigurationNodeManager(
serverInternal,
m_fixture.Config,
serverInternal.Telemetry.CreateLogger<ConfigurationNodeManager>(),
timeProvider: null,
coordinator,
pendingKeyStore: null);
var sessionId = new NodeId(Guid.NewGuid(), 1);
ISystemContext context = CreateAdminContextForSession(sessionId);
var certificateType = new NodeId(Guid.NewGuid(), 1);
using Certificate rotatedCertificate = CertificateBuilder
.Create("CN=ConcurrentApplyChanges " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
var started = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var gate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
coordinator.Stage(sessionId, new PushConfigurationOperation
{
AffectedCertificateType = certificateType,
CommitAsync = async _ =>
{
// Register the rotation FIRST (as a real Delete/UpdateCertificate
// commit would, before any further work) and only then
// pause, so the duplicate call below races in after
// the rotation already exists but before this call's
// own ApplyChangesAsync has drained it.
InvokeRegisterPendingRotation(manager, certificateType, rotatedCertificate);
started.TrySetResult(true);
await gate.Task.ConfigureAwait(false);
}
});
FieldInfo pendingApplyChangesTaskField = typeof(ConfigurationNodeManager).GetField(
"m_pendingApplyChangesTask",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Field m_pendingApplyChangesTask not found.");
var beforeTask = (Task)pendingApplyChangesTaskField.GetValue(manager)!;
// CA2025: rotatedCertificate is only read (its RawData copied)
// synchronously inside CommitAsync before the pause below, so
// it is never touched after this point; the analyzer cannot
// see that firstApply is still awaited later in this same
// method before rotatedCertificate's `using` disposes it.
#pragma warning disable CA2025
Task<ServiceResult> firstApply = InvokeApplyChangesAsync(manager, context);
#pragma warning restore CA2025
await started.Task.ConfigureAwait(false);
ServiceResult secondResult = await InvokeApplyChangesAsync(manager, context).ConfigureAwait(false);
Assert.That(
secondResult.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidState),
"the duplicate call must short-circuit while the first call's commit is still running");
gate.TrySetResult(true);
ServiceResult firstResult = await firstApply.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(firstResult), Is.True, "the first, legitimate commit must still succeed");
var afterTask = (Task)pendingApplyChangesTaskField.GetValue(manager)!;
Assert.That(
ReferenceEquals(beforeTask, afterTask),
Is.False,
"the successful commit's rotation must still be scheduled for deferred apply, " +
"not silently dropped by the concurrent duplicate call");
await manager.DrainPendingApplyChangesAsync(CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
/// Invokes the private <c>ApplyChangesAsync</c> method-handler
/// directly via reflection so two overlapping calls can be driven
/// deterministically without needing two concurrent OPC UA Method
/// Call requests.
/// </summary>
private static async Task<ServiceResult> InvokeApplyChangesAsync(
ConfigurationNodeManager manager,
ISystemContext context)
{
MethodInfo method = typeof(ConfigurationNodeManager).GetMethod(
"ApplyChangesAsync",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Method ApplyChangesAsync not found.");
var valueTask = (ValueTask<ServiceResult>)method.Invoke(
manager,
[
context,
null,
NodeId.Null,
ArrayOf<Variant>.Empty,
new System.Collections.Generic.List<Variant>(),
CancellationToken.None
])!;
return await valueTask.ConfigureAwait(false);
}
/// <summary>
/// Invokes the private <c>RegisterPendingRotation</c> helper
/// directly via reflection, exactly as a real Delete/UpdateCertificate
/// staged commit would, without needing a full certificate-store
/// round trip.
/// </summary>
private static void InvokeRegisterPendingRotation(
ConfigurationNodeManager manager,
NodeId certificateType,
Certificate oldCertificateWithKey)
{
MethodInfo method = typeof(ConfigurationNodeManager).GetMethod(
"RegisterPendingRotation",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Method RegisterPendingRotation not found.");
method.Invoke(manager, [certificateType, oldCertificateWithKey]);
}
[Test]
public async Task CancelChangesWithNoActiveTransactionReturnsBadNothingToDoAsync()
{
ISystemContext context = CreateAdminContext();
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult result = await m_configNode.CancelChanges.OnCallMethod2Async(
context,
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNothingToDo));
}
[Test]
public void CancelChangesNonAdminThrowsBadUserAccessDenied()
{
ISystemContext context = CreateAnonymousContext();
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CancelChanges.OnCallMethod2Async(
context,
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public async Task CancelChangesDiscardsStagedUpdateCertificateAsync()
{
ISystemContext context = CreateAdminContext();
ByteString beforeCancel = GetCurrentRsaCertificate(context);
await UpdateCertificateWithPfxPrivateKeyStagesCertificateAsync().ConfigureAwait(false);
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult cancelResult = await m_configNode.CancelChanges.OnCallMethod2Async(
context,
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(cancelResult), Is.True);
ByteString afterCancel = GetCurrentRsaCertificate(context);
Assert.That(afterCancel.ToArray(), Is.EqualTo(beforeCancel.ToArray()),
"cancelling the transaction must leave the certificate store untouched");
// A repeated CancelChanges call has nothing left to discard.
ServiceResult secondCancelResult = await m_configNode.CancelChanges.OnCallMethod2Async(
context,
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(secondCancelResult.StatusCode, Is.EqualTo(StatusCodes.BadNothingToDo));
}
[Test]
public async Task CancelChangesReportsBadRequestCancelledByClientInTransactionDiagnosticsAsync()
{
// OPC 10000-12 §7.10.17: "If the CancelChanges Method was
// called the value is Bad_RequestCancelledByClient."
ISystemContext context = CreateAdminContext();
await UpdateCertificateWithPfxPrivateKeyStagesCertificateAsync().ConfigureAwait(false);
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult cancelResult = await m_configNode.CancelChanges.OnCallMethod2Async(
context,
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(cancelResult), Is.True);
Assert.That(
m_configNode.TransactionDiagnostics.Result.Value,
Is.EqualTo(StatusCodes.BadRequestCancelledByClient));
// §7.10.17: a completed transaction reports the outcome as the
// Result value with a Good DataValue status (only an in-flight
// transaction reads Bad_InvalidState).
Assert.That(
m_configNode.TransactionDiagnostics.Result.StatusCode,
Is.EqualTo((StatusCode)StatusCodes.Good));
}
[Test]
public async Task UpdateCertificateFromAnotherSessionWhileTransactionActiveThrowsBadTransactionPendingAsync()
{
ISystemContext ownerContext = CreateAdminContextForSession(new NodeId(Guid.NewGuid(), 1));
ISystemContext otherContext = CreateAdminContextForSession(new NodeId(Guid.NewGuid(), 1));
ByteString currentCertificate = GetCurrentRsaCertificate(ownerContext);
using Certificate current = Certificate.FromRawData(currentCertificate);
string[] domainNames = X509Utils.GetDomainsFromCertificate(current).ToArray();
using Certificate newCertificate = DefaultCertificateFactory.Instance
.CreateApplicationCertificate(
m_fixture.Config.ApplicationUri,
m_fixture.Config.ApplicationName,
current.Subject,
domainNames)
.CreateForRSA();
ByteString privateKey = newCertificate.Export(X509ContentType.Pfx).ToByteString();
try
{
UpdateCertificateMethodStateResult ownerResult = await m_configNode.UpdateCertificate.OnCallAsync(
ownerContext,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
newCertificate.RawData.ToByteString(),
[],
"pfx",
privateKey,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(ownerResult.ServiceResult), Is.True);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.UpdateCertificate.OnCallAsync(
otherContext,
m_configNode.UpdateCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
newCertificate.RawData.ToByteString(),
[],
"pfx",
privateKey,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadTransactionPending));
// ApplyChanges/CancelChanges from the non-owning Session
// must also be rejected without disturbing the owner's
// staged transaction.
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
ServiceResult applyFromOtherResult = await m_configNode.ApplyChanges.OnCallMethod2Async(
otherContext,
m_configNode.ApplyChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(applyFromOtherResult.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
ServiceResult cancelFromOtherResult = await m_configNode.CancelChanges.OnCallMethod2Async(
otherContext,
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
Assert.That(cancelFromOtherResult.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
}
finally
{
// Always discard the staged transaction so later tests in
// this shared-fixture file are unaffected (the file-level
// [TearDown] uses CreateAdminContext(), whose NodeId.Null
// Session does not own this transaction and would
// otherwise get BadSessionIdInvalid).
var inputArguments = ArrayOf<Variant>.Empty;
var outputArguments = new System.Collections.Generic.List<Variant>();
await m_configNode.CancelChanges.OnCallMethod2Async(
ownerContext,
m_configNode.CancelChanges,
m_configNode.NodeId,
inputArguments,
outputArguments,
CancellationToken.None).ConfigureAwait(false);
}
}
private ByteString GetCurrentRsaCertificate(ISystemContext context)
{
ArrayOf<NodeId> certificateTypeIds = default;
ArrayOf<ByteString> certificates = default;
ServiceResult getResult = m_configNode.GetCertificates.OnCall(
context,
m_configNode.GetCertificates,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIds,
ref certificates);
Assert.That(ServiceResult.IsGood(getResult), Is.True);
int index = certificateTypeIds.ToList()
.FindIndex(t => t == ObjectTypeIds.RsaSha256ApplicationCertificateType);
Assert.That(index, Is.GreaterThanOrEqualTo(0));
return certificates[index];
}
[Test]
public void CreateSigningRequestWithInsecureChannelThrowsBadSecurityModeInsufficient()
{
// §7.10.10: CreateSigningRequest requires an encrypted channel.
ISystemContext context = CreateContext(
UserTokenType.UserName,
MessageSecurityMode.Sign,
ObjectIds.WellKnownRole_SecurityAdmin);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
null,
false,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSecurityModeInsufficient));
}
[Test]
public void GetCertificatesOverAuthenticatedSignChannelIsPermitted()
{
// §7.10 GetCertificates does not transfer private-key material, so
// an authenticated (Sign) channel is sufficient — it must NOT be
// rejected with BadSecurityModeInsufficient.
ISystemContext context = CreateContext(
UserTokenType.UserName,
MessageSecurityMode.Sign,
ObjectIds.WellKnownRole_SecurityAdmin);
ArrayOf<NodeId> certificateTypeIds = default;
ArrayOf<ByteString> certificates = default;
ServiceResult result = m_configNode.GetCertificates.OnCall(
context,
m_configNode.GetCertificates,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ref certificateTypeIds,
ref certificates);
Assert.That(ServiceResult.IsGood(result), Is.True);
}
[Test]
public void GetRejectedListOverUnauthenticatedChannelThrowsBadSecurityModeInsufficient()
{
ISystemContext context = CreateContext(
UserTokenType.UserName,
MessageSecurityMode.None,
ObjectIds.WellKnownRole_SecurityAdmin);
ArrayOf<ByteString> certificates = default;
ServiceResultException exception = Assert.Throws<ServiceResultException>(() =>
m_configNode.GetRejectedList.OnCall(
context,
m_configNode.GetRejectedList,
m_configNode.NodeId,
ref certificates));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSecurityModeInsufficient));
}
[Test]
public void CreateSelfSignedCertificateOverUnauthenticatedChannelThrowsBadSecurityModeInsufficient()
{
ISystemContext context = CreateContext(
UserTokenType.UserName,
MessageSecurityMode.None,
ObjectIds.WellKnownRole_SecurityAdmin);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSelfSignedCertificate.OnCallAsync(
context,
m_configNode.CreateSelfSignedCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
"CN=Test",
["localhost"],
[],
(ushort)0,
(ushort)2048,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSecurityModeInsufficient));
}
[Test]
public void CreateSelfSignedCertificateOverSignChannelWithoutAdminRoleThrowsBadUserAccessDenied()
{
// Channel security is sufficient (authenticated Sign channel) but
// the SecurityAdmin Role is missing: the failure must be the
// Role failure (Bad_UserAccessDenied), not the channel failure.
ISystemContext context = CreateContext(UserTokenType.Anonymous, MessageSecurityMode.Sign);
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSelfSignedCertificate.OnCallAsync(
context,
m_configNode.CreateSelfSignedCertificate,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
"CN=Test",
["localhost"],
[],
(ushort)0,
(ushort)2048,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
}
[Test]
public void CreateSigningRequestWithRegenerateAndShortNonceThrowsBadInvalidArgument()
{
// §7.10.10: the Nonce must be at least 32 bytes when a new private
// key is regenerated; a shorter Nonce is Bad_InvalidArgument.
ISystemContext context = CreateAdminContext();
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(async () =>
await m_configNode.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
null,
true,
ByteString.From(new byte[31]),
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
}
[Test]
public async Task CreateSigningRequestWithoutRegenerateAcceptsAnEmptyNonceAsync()
{
// The Nonce is only required when regeneratePrivateKey is true;
// reusing the existing key must accept an empty Nonce.
ISystemContext context = CreateAdminContext();
CreateSigningRequestMethodStateResult result = await m_configNode
.CreateSigningRequest.OnCallAsync(
context,
m_configNode.CreateSigningRequest,
m_configNode.NodeId,
ObjectIds.ServerConfiguration_CertificateGroups_DefaultApplicationGroup,
ObjectTypeIds.RsaSha256ApplicationCertificateType,
null,
false,
ByteString.Empty,
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
Assert.That(result.CertificateRequest.Length, Is.GreaterThan(0));
}
private static SessionSystemContext CreateAdminContext()
{
return CreateContext(
UserTokenType.UserName,
MessageSecurityMode.SignAndEncrypt,
ObjectIds.WellKnownRole_SecurityAdmin);
}
private static SessionSystemContext CreateAnonymousContext()
{
return CreateContext(UserTokenType.Anonymous, MessageSecurityMode.SignAndEncrypt);
}
private static SessionSystemContext CreateContext(
UserTokenType tokenType,
MessageSecurityMode securityMode,
params NodeId[] grantedRoles)
{
var identity = new Mock<IUserIdentity>();
identity.Setup(i => i.TokenType).Returns(tokenType);
identity.Setup(i => i.DisplayName).Returns(tokenType.ToString());
identity.Setup(i => i.GrantedRoleIds).Returns(ArrayOf.Wrapped(grantedRoles));
var endpoint = new EndpointDescription { SecurityMode = securityMode };
var channelContext = new SecureChannelContext("test", endpoint, RequestEncoding.Binary);
var operationContext = new OperationContext(
new RequestHeader(),
channelContext,
RequestType.Call,
RequestLifetime.None,
identity.Object);
return new SessionSystemContext(operationContext, s_telemetry)
{
NamespaceUris = new NamespaceTable(),
ServerUris = new StringTable()
};
}
/// <summary>
/// Creates an admin context bound to a specific, deterministic
/// Session NodeId (unlike <see cref="CreateAdminContext"/>, which
/// always resolves to <see cref="NodeId.Null"/>) so cross-Session
/// PushManagement transaction ownership can be exercised.
/// </summary>
private static SessionSystemContext CreateAdminContextForSession(NodeId sessionId)
{
var identity = new Mock<IUserIdentity>();
identity.Setup(i => i.TokenType).Returns(UserTokenType.UserName);
identity.Setup(i => i.DisplayName).Returns(nameof(UserTokenType.UserName));
identity.Setup(i => i.GrantedRoleIds).Returns(ArrayOf.Wrapped(ObjectIds.WellKnownRole_SecurityAdmin));
var session = new Mock<ISession>();
session.Setup(s => s.Id).Returns(sessionId);
session.Setup(s => s.EffectiveIdentity).Returns(identity.Object);
session.Setup(s => s.PreferredLocales).Returns([]);
var endpoint = new EndpointDescription { SecurityMode = MessageSecurityMode.SignAndEncrypt };
var channelContext = new SecureChannelContext("test", endpoint, RequestEncoding.Binary);
var operationContext = new OperationContext(
new RequestHeader(),
channelContext,
RequestType.Call,
RequestLifetime.None,
session.Object);
return new SessionSystemContext(operationContext, s_telemetry)
{
NamespaceUris = new NamespaceTable(),
ServerUris = new StringTable()
};
}
}
}