Files
UA-.NETStandard/tests/Opc.Ua.Gds.Tests/PushTest.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# Description

Nullable reference types are now enabled once, in `common.props`, and
every project in the repository builds warning-clean with them.

**Configuration**
- `<Nullable>enable</Nullable>` lives in `common.props`. All per-project
`<Nullable>` settings are removed: 178 project files (enable,
annotations, and the `disable` in the three Aot test projects).
- 419 per-file `#nullable enable`/`restore` directives and the 4
`#nullable disable` directives are removed.
- Kept on purpose:
- The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type
Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`,
`PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the
project setting in generated code, so the directive is required there.
- `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against
the 1.5.378 packages and does not import the repository props.
- `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now
describe the central setting.

**Migration** (the 43 unset and 13 annotations-only projects, roughly
10k diagnostics)
- **Production code: annotated at the declarations.** Covers
`tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the
LDS sample.
- Members, parameters and returns are nullable where null actually
flows.
- Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`,
`IsMethodTypeDesign`).
- `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take
`params object?[]`. `Template.AddReplacement` takes `string?` (rendering
already skips null replacements).
- `IRegisteredServerStore.RegisterAsync` returns
`Task<RegistrationEntry?>`: it returns null when a registration is
removed.
- Dereferences backed by an invariant (for example, the validated model)
use `!`, so runtime behaviour is unchanged.
- **Behavioural changes in production code** (small, deliberate):
- `NodeSetToModelDesign` throws `InvalidOperationException` when the
file is not a NodeSet. Before, it threw a NullReferenceException on the
next line.
- `ModelDesignValidator` starts its lookup tables empty instead of null.
A redundant `if (m_nodes == null) return;` guard is gone.
- **Test code: minimal annotations.**
  - `?` on values that can be null, `!` where the test asserts non-null.
  - `= null!` for fixture members assigned in SetUp.
- `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the
`IEncodeable` constraint.
- A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`)
folded into the adjacent `IsNull`/`Count` checks.

**Notes for reviewers**
- The diff is large (~1,500 files) but almost entirely mechanical: `?`,
`!` and directive removal. The non-annotation changes are listed above.
- Known follow-up, not done here:
`IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay
non-nullable even though the validator returns null before it loads a
dictionary. Making them nullable would ripple through every generator
call site.

## Related Issues

- Fixes #4439

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

The "no new warnings" box covers net10.0 and net48: full non-incremental
builds of `UA.slnx`, `tools/SourceGeneration.slnx`,
`tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0
warnings and 0 errors under the repository's warnings-as-errors
settings. net9.0 and net8.0 also had no nullable diagnostics, but they
were not rebuilt after the final fixes. Tests have not been run locally
yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:48:13 +02:00

2180 lines
99 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Opc.Ua.Gds.Client;
using Opc.Ua.Gds.Server;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Test;
using Opc.Ua.Tests;
using OpcUa = Opc.Ua;
namespace Opc.Ua.Gds.Tests
{
[TestFixture]
[Category("GDSPush")]
[Category("GDS")]
[TestFixtureSource(nameof(FixtureArgs))]
[SetCulture("en-us")]
[SetUICulture("en-us")]
[NonParallelizable]
public class PushTest
{
private static readonly ICertificateFactory s_factory = DefaultCertificateFactory.Instance;
private static readonly HashSet<string> s_supportedPolicyUris =
[
.. SecurityPolicies.Default.GetDisplayNames().Select(name => SecurityPolicies.Default.GetUri(name)!)
];
/// <summary>
/// CertificateTypes to run the Test with.
/// For ECC types, the additional fourth element is the expected curve friendly name.
/// </summary>
public static readonly object[] FixtureArgs =
[
new object[]
{
nameof(OpcUa.ObjectTypeIds.RsaSha256ApplicationCertificateType),
OpcUa.ObjectTypeIds.RsaSha256ApplicationCertificateType,
SecurityPolicies.Aes256_Sha256_RsaPss,
null!
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccNistP256ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccNistP256ApplicationCertificateType,
SecurityPolicies.ECC_nistP256,
ECCurve.NamedCurves.nistP256
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccNistP256ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccNistP256ApplicationCertificateType,
SecurityPolicies.ECC_nistP256_AesGcm,
ECCurve.NamedCurves.nistP256
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccNistP256ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccNistP256ApplicationCertificateType,
SecurityPolicies.ECC_nistP256_ChaChaPoly,
ECCurve.NamedCurves.nistP256
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccNistP384ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccNistP384ApplicationCertificateType,
SecurityPolicies.ECC_nistP384,
ECCurve.NamedCurves.nistP384
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccNistP384ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccNistP384ApplicationCertificateType,
SecurityPolicies.ECC_nistP384_AesGcm,
ECCurve.NamedCurves.nistP384
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccNistP384ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccNistP384ApplicationCertificateType,
SecurityPolicies.ECC_nistP384_ChaChaPoly,
ECCurve.NamedCurves.nistP384
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType,
SecurityPolicies.ECC_brainpoolP256r1,
ECCurve.NamedCurves.brainpoolP256r1
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType,
SecurityPolicies.ECC_brainpoolP256r1_AesGcm,
ECCurve.NamedCurves.brainpoolP256r1
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccBrainpoolP256r1ApplicationCertificateType,
SecurityPolicies.ECC_brainpoolP256r1_ChaChaPoly,
ECCurve.NamedCurves.brainpoolP256r1
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType,
SecurityPolicies.ECC_brainpoolP384r1,
ECCurve.NamedCurves.brainpoolP384r1
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType,
SecurityPolicies.ECC_brainpoolP384r1_AesGcm,
ECCurve.NamedCurves.brainpoolP384r1
},
new object[]
{
nameof(OpcUa.ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType),
OpcUa.ObjectTypeIds.EccBrainpoolP384r1ApplicationCertificateType,
SecurityPolicies.ECC_brainpoolP384r1_ChaChaPoly,
ECCurve.NamedCurves.brainpoolP384r1
}
];
public PushTest(string certificateTypeString, NodeId certificateType, string securityPolicyUri, ECCurve? curve)
{
if (!s_supportedPolicyUris.Contains(securityPolicyUri))
{
Assert.Ignore(
$"Security policy {securityPolicyUri} is not supported on this runtime.");
}
if (!Utils.IsSupportedCertificateType(certificateType))
{
Assert.Ignore(
$"Certificate type {certificateTypeString} is not supported on this platform.");
}
// Skip brainpool curves on Mac OS
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) &&
(securityPolicyUri.Contains("ECC_brainpoolP256r1", StringComparison.Ordinal) ||
securityPolicyUri.Contains("ECC_brainpoolP384r1", StringComparison.Ordinal)))
{
Assert.Ignore("Brainpool curve is not supported on Mac OS.");
}
// If a curve name is provided, perform extra check if ecc is supported
if (curve != null && !IsCurveSupported(curve.Value))
{
Assert.Ignore("ECC curve is not supported on this platform.");
}
m_certificateTypeString = certificateTypeString;
m_certificateType = certificateType;
m_securityPolicyUri = securityPolicyUri;
}
private static bool IsCurveSupported(ECCurve curve)
{
ECDsa? key = null;
try
{
key = ECDsa.Create(curve);
}
catch
{
return false;
}
finally
{
key?.Dispose();
}
return true;
}
/// <summary>
/// Overrides ToString to display active certificate type in Test Explorer.
/// </summary>
public override string ToString()
{
return $"{nameof(PushTest)} [{m_certificateTypeString}]";
}
/// <summary>
/// Set up a Global Discovery Server and Client instance and connect the session
/// </summary>
[OneTimeSetUp]
protected async Task OneTimeSetUpAsync()
{
// start GDS first clean, then restart server
// to ensure the application cert is not 'fresh'
m_telemetry = NUnitTelemetryContext.Create();
m_server = await TestUtils.StartGDSAsync(true, CertificateStoreType.Directory).ConfigureAwait(false);
await m_server.DisposeAsync().ConfigureAwait(false);
string customBasePath = Path.Combine(Path.GetTempPath(), "OPC", "GDS", "CA", "custom");
var customGroup = new CertificateGroupConfiguration
{
Id = kCustomGroupId,
CertificateTypes = ["RsaSha256ApplicationCertificateType"],
SubjectName = "CN=GDS Custom CA, O=OPC Foundation",
BaseStorePath = customBasePath,
DefaultCertificateLifetime = 12,
DefaultCertificateKeySize = 2048,
DefaultCertificateHashSize = 256,
CACertificateLifetime = 60,
CACertificateKeySize = 4096,
CACertificateHashSize = 512
};
await Task.Delay(1000).ConfigureAwait(false);
m_server = await TestUtils.StartGDSAsync(false, CertificateStoreType.Directory, additionalCertGroups: [customGroup]).ConfigureAwait(false);
m_randomSource = new RandomSource(kRandomStart);
// load clients
m_gdsClient = new GlobalDiscoveryTestClient(true, m_telemetry);
await m_gdsClient.LoadClientConfigurationAsync(m_server.BasePort).ConfigureAwait(false);
m_pushClient = new ServerConfigurationPushTestClient(true, m_telemetry);
await m_pushClient.LoadClientConfigurationAsync(m_server.BasePort)
.ConfigureAwait(false);
// connect once
await m_gdsClient.GDSClient.ConnectAsync(m_gdsClient.GDSClient.EndpointUrl!)
.ConfigureAwait(false);
try
{
await m_pushClient.ConnectAsync(m_securityPolicyUri)
.ConfigureAwait(false);
}
catch (ArgumentException ex) when (
ex.Message.Contains("No endpoint found for SecurityPolicyUri", StringComparison.Ordinal))
{
Assert.Ignore(
$"Security policy {m_securityPolicyUri} is not advertised by the GDS test server.");
}
await ConnectGDSClientAsync(true).ConfigureAwait(false);
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
await RegisterPushServerApplicationAsync(m_pushClient.PushClient.EndpointUrl!, telemetry).ConfigureAwait(false);
m_selfSignedServerCert = Certificate.FromRawData(
m_pushClient.PushClient.Session!.ConfiguredEndpoint.Description.ServerCertificate);
m_domainNames = [.. X509Utils.GetDomainsFromCertificate(m_selfSignedServerCert)];
await CreateCATestCertsAsync(m_pushClient.TempStorePath, telemetry).ConfigureAwait(false);
}
/// <summary>
/// Tear down the Global Discovery Server and disconnect the Client
/// </summary>
[OneTimeTearDown]
protected async Task OneTimeTearDownAsync()
{
try
{
await ConnectGDSClientAsync(true).ConfigureAwait(false);
await UnRegisterPushServerApplicationAsync().ConfigureAwait(false);
await m_gdsClient.DisconnectClientAsync().ConfigureAwait(false);
await m_pushClient.DisconnectClientAsync().ConfigureAwait(false);
await m_server.DisposeAsync().ConfigureAwait(false);
}
catch
{
}
finally
{
m_pushClient.Dispose();
m_gdsClient.Dispose();
m_selfSignedServerCert.Dispose();
m_selfSignedServerCert = null!;
m_caCert?.Dispose();
m_caCert = null!;
m_gdsClient = null!;
m_pushClient = null!;
m_server = null!;
}
}
[SetUp]
protected void SetUp()
{
}
[TearDown]
protected async Task TearDownAsync()
{
await DisconnectGDSClientAsync().ConfigureAwait(false);
await DisconnectPushClientAsync().ConfigureAwait(false);
}
/// <summary>
/// The push client monitors Server_ServerStatus once connected and
/// raises ServerStatusChanged for the initial value of the item.
/// </summary>
[Test]
[Order(99)]
public async Task ServerStatusChangedIsRaisedAsync()
{
var received = new TaskCompletionSource<Gds.Client.ServerStatusChangedEventArgs>(
TaskCreationOptions.RunContinuationsAsynchronously);
EventHandler<Gds.Client.ServerStatusChangedEventArgs> handler =
(_, e) => received.TrySetResult(e);
m_pushClient.PushClient.ServerStatusChanged += handler;
try
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
Task completed = await Task.WhenAny(
received.Task,
Task.Delay(TimeSpan.FromSeconds(30))).ConfigureAwait(false);
Assert.That(
completed,
Is.SameAs(received.Task),
"ServerStatusChanged was not raised within 30 seconds.");
Gds.Client.ServerStatusChangedEventArgs status = await received.Task
.ConfigureAwait(false);
Assert.That(status.CurrentState, Is.EqualTo(ServerState.Running));
Assert.That(ServiceResult.IsGood(status.Status), Is.True);
Assert.That(status.CurrentTime, Is.Not.Default);
}
finally
{
m_pushClient.PushClient.ServerStatusChanged -= handler;
}
}
[Test]
[Order(100)]
public async Task GetSupportedKeyFormatsAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
ArrayOf<string> keyFormats = await m_pushClient.PushClient.GetSupportedKeyFormatsAsync().ConfigureAwait(false);
Assert.That(keyFormats.IsNull, Is.False);
}
[Test]
[Order(200)]
public async Task ReadTrustListAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
TrustListDataType allTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(allTrustList, Is.Not.Null);
Assert.That(allTrustList.IssuerCertificates.IsNull, Is.False);
Assert.That(allTrustList.IssuerCrls.IsNull, Is.False);
Assert.That(allTrustList.TrustedCertificates.IsNull, Is.False);
Assert.That(allTrustList.TrustedCrls.IsNull, Is.False);
TrustListDataType noneTrustList = await m_pushClient.PushClient
.ReadTrustListAsync(TrustListMasks.None).ConfigureAwait(false);
Assert.That(noneTrustList, Is.Not.Null);
Assert.That(noneTrustList.IssuerCertificates.IsNull, Is.False);
Assert.That(noneTrustList.IssuerCrls.IsNull, Is.False);
Assert.That(noneTrustList.TrustedCertificates.IsNull, Is.False);
Assert.That(noneTrustList.TrustedCrls.IsNull, Is.False);
Assert.That(noneTrustList.IssuerCertificates.Count, Is.Zero);
Assert.That(noneTrustList.IssuerCrls.Count, Is.Zero);
Assert.That(noneTrustList.TrustedCertificates.Count, Is.Zero);
Assert.That(noneTrustList.TrustedCrls.Count, Is.Zero);
TrustListDataType issuerTrustList = await m_pushClient.PushClient.ReadTrustListAsync(
(TrustListMasks)((int)TrustListMasks.IssuerCertificates |
(int)TrustListMasks.IssuerCrls)).ConfigureAwait(false);
Assert.That(issuerTrustList, Is.Not.Null);
Assert.That(issuerTrustList.IssuerCertificates.IsNull, Is.False);
Assert.That(issuerTrustList.IssuerCrls.IsNull, Is.False);
Assert.That(issuerTrustList.TrustedCertificates.IsNull, Is.False);
Assert.That(issuerTrustList.TrustedCrls.IsNull, Is.False);
Assert.That(issuerTrustList.IssuerCertificates.Count, Is.EqualTo(allTrustList.IssuerCertificates.Count));
Assert.That(issuerTrustList.IssuerCrls.Count, Is.EqualTo(allTrustList.IssuerCrls.Count));
Assert.That(issuerTrustList.TrustedCertificates.Count, Is.Zero);
Assert.That(issuerTrustList.TrustedCrls.Count, Is.Zero);
TrustListDataType trustedTrustList = await m_pushClient.PushClient.ReadTrustListAsync(
(TrustListMasks)((int)TrustListMasks.TrustedCertificates |
(int)TrustListMasks.TrustedCrls)).ConfigureAwait(false);
Assert.That(trustedTrustList, Is.Not.Null);
Assert.That(trustedTrustList.IssuerCertificates.IsNull, Is.False);
Assert.That(trustedTrustList.IssuerCrls.IsNull, Is.False);
Assert.That(trustedTrustList.TrustedCertificates.IsNull, Is.False);
Assert.That(trustedTrustList.TrustedCrls.IsNull, Is.False);
Assert.That(trustedTrustList.IssuerCertificates.Count, Is.Zero);
Assert.That(trustedTrustList.IssuerCrls.Count, Is.Zero);
Assert.That(trustedTrustList.TrustedCertificates.Count, Is.EqualTo(allTrustList.TrustedCertificates.Count));
Assert.That(trustedTrustList.TrustedCrls.Count, Is.EqualTo(allTrustList.TrustedCrls.Count));
}
/// <summary>
/// OPC 10000-12 §7.10.2: TrustList updates are staged as part of a
/// PushManagement transaction and only take effect once
/// <c>ApplyChanges</c> is called; <c>CloseAndUpdate</c> therefore
/// always reports <c>applyChangesRequired=true</c> and the write is
/// not observable via <c>ReadTrustList</c> until the transaction is
/// applied.
/// </summary>
[Test]
[Order(300)]
public async Task UpdateTrustListAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
TrustListDataType fullTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
TrustListDataType emptyTrustList = await m_pushClient.PushClient
.ReadTrustListAsync(TrustListMasks.None).ConfigureAwait(false);
emptyTrustList.SpecifiedLists = (uint)TrustListMasks.All;
bool applyChangesRequired = await m_pushClient.PushClient
.UpdateTrustListAsync(emptyTrustList).ConfigureAwait(false);
Assert.That(applyChangesRequired, Is.True);
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
TrustListDataType expectEmptyTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(Utils.IsEqual(expectEmptyTrustList, emptyTrustList), Is.True);
applyChangesRequired = await m_pushClient.PushClient
.UpdateTrustListAsync(fullTrustList).ConfigureAwait(false);
Assert.That(applyChangesRequired, Is.True);
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
TrustListDataType expectFullTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(Utils.IsEqual(expectFullTrustList, fullTrustList), Is.True);
}
[Test]
[Order(310)]
public async Task UpdateTrustListOfCustomGroupAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
NodeId groupId = await GetCustomGroupIdAsync().ConfigureAwait(false);
TrustListDataType fullTrustList = await m_pushClient.PushClient.ReadTrustListAsync(groupId).ConfigureAwait(false);
TrustListDataType emptyTrustList = await m_pushClient.PushClient
.ReadTrustListAsync(groupId, TrustListMasks.None).ConfigureAwait(false);
emptyTrustList.SpecifiedLists = (uint)TrustListMasks.All;
// The GDS hosts the groups of its own Directory non-transactionally,
// so CloseAndUpdate writes the stores straight away and reports that
// no ApplyChanges is needed. Reading the list back is what proves the
// write landed; calling ApplyChanges here would stage nothing and
// return Bad_NothingToDo.
bool applyChangesRequired = await m_pushClient.PushClient
.UpdateTrustListAsync(groupId, emptyTrustList, 0).ConfigureAwait(false);
Assert.That(applyChangesRequired, Is.False);
TrustListDataType expectEmptyTrustList = await m_pushClient.PushClient.ReadTrustListAsync(groupId).ConfigureAwait(false);
Assert.That(Utils.IsEqual(expectEmptyTrustList, emptyTrustList), Is.True);
applyChangesRequired = await m_pushClient.PushClient
.UpdateTrustListAsync(groupId, fullTrustList, 0).ConfigureAwait(false);
Assert.That(applyChangesRequired, Is.False);
TrustListDataType expectFullTrustList = await m_pushClient.PushClient.ReadTrustListAsync(groupId).ConfigureAwait(false);
Assert.That(Utils.IsEqual(expectFullTrustList, fullTrustList), Is.True);
}
[Test]
[Order(311)]
public async Task AddRemoveCertOfCustomGroupAsync()
{
using Certificate trustedCert = s_factory
.CreateCertificate("CN=Push Server Test")
.CreateForRSA();
using Certificate issuerCert = s_factory
.CreateCertificate("CN=Push Server Test")
.CreateForRSA();
await ConnectPushClientAsync(true).ConfigureAwait(false);
NodeId groupId = await GetCustomGroupIdAsync().ConfigureAwait(false);
TrustListDataType beforeTrustList = await m_pushClient.PushClient.ReadTrustListAsync(groupId).ConfigureAwait(false);
// Non-transactional group: AddCertificate/RemoveCertificate write the
// store as they are called, so there is nothing for ApplyChanges to
// commit afterwards.
await m_pushClient.PushClient.AddCertificateAsync(groupId, trustedCert, true).ConfigureAwait(false);
// OPC 10000-12 §7.8.2.6: issuer certificates cannot be added with
// AddCertificate (IsTrustedCertificate FALSE is Bad_CertificateInvalid).
ServiceResultException addIssuer = Assert.ThrowsAsync<ServiceResultException>(() =>
m_pushClient.PushClient.AddCertificateAsync(groupId, issuerCert, false).AsTask());
Assert.That(addIssuer.StatusCode, Is.EqualTo(StatusCodes.BadCertificateInvalid));
TrustListDataType afterAddTrustList = await m_pushClient.PushClient.ReadTrustListAsync(groupId).ConfigureAwait(false);
Assert.That(
afterAddTrustList.TrustedCertificates.Count,
Is.GreaterThan(beforeTrustList.TrustedCertificates.Count));
Assert.That(
afterAddTrustList.IssuerCertificates.Count,
Is.EqualTo(beforeTrustList.IssuerCertificates.Count));
Assert.That(Utils.IsEqual(beforeTrustList, afterAddTrustList), Is.False);
await m_pushClient.PushClient.RemoveCertificateAsync(groupId, trustedCert.Thumbprint, true).ConfigureAwait(false);
TrustListDataType afterRemoveTrustList = await m_pushClient.PushClient.ReadTrustListAsync(groupId).ConfigureAwait(false);
Assert.That(Utils.IsEqual(beforeTrustList, afterRemoveTrustList), Is.True);
}
/// <summary>
/// Resolves the GDS certificate group this fixture adds through
/// configuration, by browse name. The group must be reachable by
/// browsing its folder, not just by <c>GetCertificateGroups</c>
/// (OPC 10000-12 §7.8.2), which is what this lookup also asserts.
/// </summary>
private async Task<NodeId> GetCustomGroupIdAsync()
{
List<ReferenceDescription> groups = await FindChildrenByTypeDefinitionRecursiveAsync(
OpcUa.ObjectIds.ObjectsFolder,
OpcUa.ObjectTypeIds.CertificateGroupType)
.ConfigureAwait(false);
ReferenceDescription customGroup = groups
.Find(group => group.BrowseName.Name == kCustomGroupId)!;
Assert.That(
customGroup,
Is.Not.Null,
$"The '{kCustomGroupId}' certificate group must be browseable below the Objects folder.");
return ExpandedNodeId.ToNodeId(
customGroup.NodeId,
m_pushClient.PushClient.Session!.NamespaceUris);
}
[Test]
[Order(301)]
public async Task AddRemoveCertAsync()
{
using Certificate trustedCert = s_factory
.CreateApplicationCertificate("uri:x:y:z", "TrustedCert", "CN=Push Server Test")
.CreateForRSA();
using Certificate issuerCert = s_factory
.CreateApplicationCertificate("uri:x:y:z", "IssuerCert", "CN=Push Server Test")
.CreateForRSA();
await ConnectPushClientAsync(true).ConfigureAwait(false);
TrustListDataType beforeTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
await m_pushClient.PushClient.AddCertificateAsync(trustedCert, true).ConfigureAwait(false);
ServiceResultException addIssuer = Assert.ThrowsAsync<ServiceResultException>(() =>
m_pushClient.PushClient.AddCertificateAsync(issuerCert, false).AsTask());
Assert.That(addIssuer.StatusCode, Is.EqualTo(StatusCodes.BadCertificateInvalid));
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
TrustListDataType afterAddTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(
afterAddTrustList.TrustedCertificates.Count,
Is.GreaterThan(beforeTrustList.TrustedCertificates.Count));
Assert.That(
afterAddTrustList.IssuerCertificates.Count,
Is.EqualTo(beforeTrustList.IssuerCertificates.Count));
Assert.That(Utils.IsEqual(beforeTrustList, afterAddTrustList), Is.False);
await m_pushClient.PushClient.RemoveCertificateAsync(trustedCert.Thumbprint, true).ConfigureAwait(false);
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
TrustListDataType afterRemoveTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(Utils.IsEqual(beforeTrustList, afterRemoveTrustList), Is.True);
}
[Test]
[Order(302)]
public async Task AddRemoveCATrustedCertAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
TrustListDataType beforeTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
await m_pushClient.PushClient.AddCertificateAsync(m_caCert, true).ConfigureAwait(false);
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
TrustListDataType afterAddTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(
afterAddTrustList.TrustedCertificates.Count,
Is.GreaterThan(beforeTrustList.TrustedCertificates.Count));
Assert.That(beforeTrustList.TrustedCrls.Count, Is.EqualTo(afterAddTrustList.TrustedCrls.Count));
Assert.That(Utils.IsEqual(beforeTrustList, afterAddTrustList), Is.False);
ServiceResultException serviceResultException = Assert
.ThrowsAsync<ServiceResultException>(() =>
m_pushClient.PushClient.RemoveCertificateAsync(m_caCert.Thumbprint, false).AsTask());
Assert.That(
serviceResultException.StatusCode,
Is.EqualTo(StatusCodes.BadInvalidArgument),
serviceResultException.Message);
TrustListDataType afterRemoveTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(Utils.IsEqual(beforeTrustList, afterRemoveTrustList), Is.False);
await m_pushClient.PushClient.RemoveCertificateAsync(m_caCert.Thumbprint, true).ConfigureAwait(false);
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
afterRemoveTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(Utils.IsEqual(beforeTrustList, afterRemoveTrustList), Is.True);
}
[Test]
[Order(303)]
public async Task AddRemoveCAIssuerCertAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
TrustListDataType beforeTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
// Issuer certificates are added through the TrustList file
// (AddCertificate only accepts trusted certificates, §7.8.2.6).
TrustListDataType withIssuer = await m_pushClient.PushClient
.ReadTrustListAsync(TrustListMasks.IssuerCertificates).ConfigureAwait(false);
withIssuer.IssuerCertificates = withIssuer.IssuerCertificates.AddItem(
m_caCert.RawData.ToByteString());
bool applyChangesRequired = await m_pushClient.PushClient
.UpdateTrustListAsync(withIssuer).ConfigureAwait(false);
Assert.That(applyChangesRequired, Is.True);
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
TrustListDataType afterAddTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(
afterAddTrustList.IssuerCertificates.Count,
Is.GreaterThan(beforeTrustList.IssuerCertificates.Count));
Assert.That(beforeTrustList.IssuerCrls.Count, Is.EqualTo(afterAddTrustList.IssuerCrls.Count));
Assert.That(Utils.IsEqual(beforeTrustList, afterAddTrustList), Is.False);
await Assert.ThatAsync(
() => m_pushClient.PushClient.RemoveCertificateAsync(m_caCert.Thumbprint, true).AsTask(),
Throws.Exception).ConfigureAwait(false);
TrustListDataType afterRemoveTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(Utils.IsEqual(beforeTrustList, afterRemoveTrustList), Is.False);
await m_pushClient.PushClient.RemoveCertificateAsync(m_caCert.Thumbprint, false).ConfigureAwait(false);
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
afterRemoveTrustList = await m_pushClient.PushClient.ReadTrustListAsync().ConfigureAwait(false);
Assert.That(Utils.IsEqual(beforeTrustList, afterRemoveTrustList), Is.True);
}
[Test]
[Order(400)]
public async Task CreateSigningRequestBadParmsAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
var invalidCertGroup = new NodeId(333);
var invalidCertType = new NodeId(Guid.NewGuid());
await Assert.ThatAsync(
() => m_pushClient.PushClient
.CreateSigningRequestAsync(invalidCertGroup, default, null!, false, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() => m_pushClient.PushClient
.CreateSigningRequestAsync(default, invalidCertType, default!, false, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() => m_pushClient.PushClient
.CreateSigningRequestAsync(default, default, null!, false, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() => m_pushClient.PushClient
.CreateSigningRequestAsync(invalidCertGroup, invalidCertType, null!, false, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
}
[Test]
[Order(401)]
public async Task CreateSigningRequestNullParmsAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
ByteString csr = await m_pushClient.PushClient
.CreateSigningRequestAsync(default, m_certificateType, null!, false, default).ConfigureAwait(false);
Assert.That(csr.IsEmpty, Is.False);
}
[Test]
[Order(402)]
public async Task CreateSigningRequestRsaMinNullParmsAsync()
{
#if NET5_0_OR_GREATER
Assert
.Ignore("SHA1 not supported on .NET 5.0 or greater");
#endif
await ConnectPushClientAsync(true).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.CreateSigningRequestAsync(
default,
OpcUa.ObjectTypeIds.RsaMinApplicationCertificateType,
null!,
false,
default
).AsTask(),
Throws.Exception).ConfigureAwait(false);
}
[Test]
[Order(409)]
public async Task CreateSigningRequestAllParmsAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
ByteString nonce = ByteString.Empty;
ByteString csr = await m_pushClient.PushClient.CreateSigningRequestAsync(
m_pushClient.PushClient.DefaultApplicationGroup,
m_certificateType,
string.Empty,
false,
nonce).ConfigureAwait(false);
Assert.That(csr.IsEmpty, Is.False);
}
[Test]
[Order(410)]
public async Task CreateSigningRequestNullParmsWithNewPrivateKeyAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
byte[] regenerateNonce = new byte[32];
m_randomSource.NextBytes(regenerateNonce, 0, regenerateNonce.Length);
if (CryptoUtils.GetCurveFromCertificateTypeId(m_certificateType) != null &&
!Opc.Ua.Server.AdditionalEntropyCertificateKeyGenerator.IsEccKeyRegenerationSupported)
{
// §7.10.10: genuine additional-entropy incorporation into an ECC
// private key is unavailable on target frameworks that cannot
// import a private-only EC scalar (.NET Framework), so a
// regenerate-key request for an ECC
// CertificateType must be rejected with Bad_NotSupported rather
// than silently ignoring the mandated Nonce.
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
() => m_pushClient.PushClient.CreateSigningRequestAsync(
default,
m_certificateType,
null!,
true,
ByteString.From(regenerateNonce)).AsTask());
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
return;
}
ByteString csr = await m_pushClient.PushClient.CreateSigningRequestAsync(
default,
m_certificateType,
null!,
true,
ByteString.From(regenerateNonce)).ConfigureAwait(false);
Assert.That(csr.IsEmpty, Is.False);
}
[Test]
[Order(419)]
public async Task CreateSigningRequestAllParmsWithNewPrivateKeyAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
byte[] nonce = new byte[32];
m_randomSource.NextBytes(nonce, 0, nonce.Length);
if (CryptoUtils.GetCurveFromCertificateTypeId(m_certificateType) != null &&
!Opc.Ua.Server.AdditionalEntropyCertificateKeyGenerator.IsEccKeyRegenerationSupported)
{
// §7.10.10: ECC regenerate-key requests are rejected with
// Bad_NotSupported when a private-only EC scalar cannot be
// imported (see
// CreateSigningRequestNullParmsWithNewPrivateKeyAsync).
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
() => m_pushClient.PushClient.CreateSigningRequestAsync(
m_pushClient.PushClient.DefaultApplicationGroup,
m_certificateType,
string.Empty,
true,
ByteString.From(nonce)).AsTask());
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
return;
}
ByteString csr = await m_pushClient.PushClient.CreateSigningRequestAsync(
m_pushClient.PushClient.DefaultApplicationGroup,
m_certificateType,
string.Empty,
true,
ByteString.From(nonce)).ConfigureAwait(false);
Assert.That(csr.IsEmpty, Is.False);
}
[Test]
[Order(500)]
public async Task UpdateCertificateSelfSignedNoPrivateKeyAssertsAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
using Certificate invalidCert = s_factory
.CreateApplicationCertificate("uri:x:y:z", "TestApp", "CN=Push Server Test")
.CreateForRSA();
using var serverCert = Certificate.FromRawData(
m_pushClient.PushClient.Session!.ConfiguredEndpoint.Description.ServerCertificate);
if (!X509Utils.CompareDistinguishedName(serverCert.Subject, serverCert.Issuer))
{
Assert.Ignore("Server has no self signed cert in use.");
}
byte[] invalidRawCert = [0xba, 0xd0, 0xbe, 0xef, 3];
// negative test all parameter combinations
var invalidCertGroup = new NodeId(333);
var invalidCertType = new NodeId(Guid.NewGuid());
await Assert.ThatAsync(
() => m_pushClient.PushClient.UpdateCertificateAsync(default, default, default, null!, default, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.UpdateCertificateAsync(
invalidCertGroup,
default,
serverCert.RawData.ToByteString(),
null!,
default,
default
).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.UpdateCertificateAsync(
default,
invalidCertType,
serverCert.RawData.ToByteString(),
null!,
default,
default
).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.UpdateCertificateAsync(
invalidCertGroup,
invalidCertType,
serverCert.RawData.ToByteString(),
null!,
default,
default
).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() => m_pushClient.PushClient
.UpdateCertificateAsync(default, default, invalidRawCert.ToByteString(), null!, default, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() => m_pushClient.PushClient
.UpdateCertificateAsync(default, default, invalidCert.RawData.ToByteString(), null!, default, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() => m_pushClient.PushClient
.UpdateCertificateAsync(default, default, serverCert.RawData.ToByteString(), "XYZ", default, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.UpdateCertificateAsync(
default,
default,
serverCert.RawData.ToByteString(),
"XYZ",
invalidCert.RawData.ToByteString(),
default
).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.UpdateCertificateAsync(
default,
default,
invalidCert.RawData.ToByteString(),
null!,
default,
[serverCert.RawData.ToByteString(), invalidCert.RawData.ToByteString()]
).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.UpdateCertificateAsync(
default,
default,
default,
null!,
default,
[serverCert.RawData.ToByteString(), invalidCert.RawData.ToByteString()]
).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.UpdateCertificateAsync(
default,
default,
invalidRawCert.ToByteString(),
null!,
default,
[serverCert.RawData.ToByteString(), invalidCert.RawData.ToByteString()]
).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.UpdateCertificateAsync(
default,
default,
serverCert.RawData.ToByteString(),
null!,
default,
[serverCert.RawData.ToByteString(), invalidRawCert.ToByteString()]
).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() => m_pushClient.PushClient
.UpdateCertificateAsync(default, default, serverCert.RawData.ToByteString(), null!, default, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
}
[Test]
[Order(501)]
public async Task UpdateCertificateSelfSignedNoPrivateKeyAsync()
{
if (m_certificateType != OpcUa.ObjectTypeIds.RsaSha256ApplicationCertificateType)
{
Assert.Ignore("Test only supported for RSA");
}
await ConnectPushClientAsync(true).ConfigureAwait(false);
using var serverCert = Certificate.FromRawData(
m_pushClient.PushClient.Session!.ConfiguredEndpoint.Description.ServerCertificate);
if (!X509Utils.CompareDistinguishedName(serverCert.Subject, serverCert.Issuer))
{
Assert.Ignore("Server has no self signed cert in use.");
}
bool success = await m_pushClient.PushClient.UpdateCertificateAsync(
default,
m_certificateType,
serverCert.RawData.ToByteString(),
null!,
default,
default).ConfigureAwait(false);
if (success)
{
await ApplyChangesIgnoreChannelTearDownAsync().ConfigureAwait(false);
}
await VerifyNewPushServerCertAsync(serverCert.RawData.ToByteString()).ConfigureAwait(false);
}
[Test]
[Order(509)]
public async Task UpdateCertificateCASignedRegeneratePrivateKeyAsync()
{
await UpdateCertificateCASignedAsync(true).ConfigureAwait(false);
}
[Test]
[Order(510)]
public async Task UpdateCertificateCASignedAsync()
{
await UpdateCertificateCASignedAsync(false).ConfigureAwait(false);
}
public async Task UpdateCertificateCASignedAsync(bool regeneratePrivateKey)
{
#if NETFRAMEWORK
if (m_certificateType != OpcUa.ObjectTypeIds.RsaMinApplicationCertificateType &&
m_certificateType != OpcUa.ObjectTypeIds.RsaSha256ApplicationCertificateType)
{
Assert.Ignore("ECC signing requests not yet supported on .NET Framework");
}
#endif
await ConnectPushClientAsync(true).ConfigureAwait(false);
await ConnectGDSClientAsync(true).ConfigureAwait(false);
TestContext.Out.WriteLine("Create Signing Request");
ByteString regenerateNonce = default;
if (regeneratePrivateKey)
{
// §7.10.10: a regenerated key requires >= 32 bytes of nonce entropy.
byte[] nonce = new byte[32];
m_randomSource.NextBytes(nonce, 0, nonce.Length);
regenerateNonce = ByteString.From(nonce);
}
if (regeneratePrivateKey &&
CryptoUtils.GetCurveFromCertificateTypeId(m_certificateType) != null &&
!Opc.Ua.Server.AdditionalEntropyCertificateKeyGenerator.IsEccKeyRegenerationSupported)
{
// §7.10.10: ECC regenerate-key requests are rejected with
// Bad_NotSupported when the server stack cannot import a
// private-only EC scalar (.NET Framework).
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
() => m_pushClient.PushClient.CreateSigningRequestAsync(
default,
m_certificateType,
m_selfSignedServerCert.Subject + "2",
regeneratePrivateKey,
regenerateNonce).AsTask());
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
return;
}
ByteString csr = await m_pushClient.PushClient.CreateSigningRequestAsync(
default,
m_certificateType,
m_selfSignedServerCert.Subject + "2",
regeneratePrivateKey,
regenerateNonce).ConfigureAwait(false);
Assert.That(csr.IsEmpty, Is.False);
TestContext.Out.WriteLine("Start Signing Request");
NodeId requestId = await m_gdsClient.GDSClient.StartSigningRequestAsync(
m_applicationRecord.ApplicationId,
default,
m_certificateType,
csr).ConfigureAwait(false);
Assert.That(requestId.IsNull, Is.False);
ByteString privateKey = default;
ByteString certificate = default;
ArrayOf<ByteString> issuerCertificates = default;
Thread.Sleep(1000);
DateTime now = DateTime.UtcNow;
do
{
try
{
TestContext.Out.WriteLine("Finish Signing Request");
(certificate, privateKey, issuerCertificates) = await m_gdsClient.GDSClient.FinishRequestAsync(
m_applicationRecord.ApplicationId,
requestId).ConfigureAwait(false);
}
catch (ServiceResultException sre)
{
// wait if GDS requires manual approval of cert request
if (sre.StatusCode == StatusCodes.BadNothingToDo &&
now.AddMinutes(5) > DateTime.UtcNow)
{
Thread.Sleep(10000);
}
else
{
throw;
}
}
} while (certificate.IsEmpty);
Assert.That(issuerCertificates.IsNull, Is.False);
Assert.That(privateKey.IsEmpty, Is.True);
await DisconnectGDSClientAsync().ConfigureAwait(false);
TestContext.Out.WriteLine("Update Certificate");
bool success = await m_pushClient.PushClient.UpdateCertificateAsync(
default,
m_certificateType,
certificate,
null!,
default,
issuerCertificates).ConfigureAwait(false);
if (success)
{
TestContext.Out.WriteLine("Apply Changes");
await ApplyChangesIgnoreChannelTearDownAsync().ConfigureAwait(false);
}
TestContext.Out.WriteLine("Verify Cert Update");
await VerifyNewPushServerCertAsync(certificate).ConfigureAwait(false);
}
[Test]
[Order(520)]
public async Task UpdateCertificateSelfSignedPFXAsync()
{
await UpdateCertificateSelfSignedAsync("PFX").ConfigureAwait(false);
}
[Test]
[Order(530)]
public async Task UpdateCertificateSelfSignedPEMAsync()
{
await UpdateCertificateSelfSignedAsync("PEM").ConfigureAwait(false);
}
public async Task UpdateCertificateSelfSignedAsync(string keyFormat)
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
ArrayOf<string> keyFormats = await m_pushClient.PushClient.GetSupportedKeyFormatsAsync().ConfigureAwait(false);
if (!keyFormats.Contains(keyFormat))
{
Assert
.Ignore($"Push server doesn't support {keyFormat} key update");
}
Certificate newCert;
ECCurve? curve = CryptoUtils.GetCurveFromCertificateTypeId(m_certificateType);
if (curve != null)
{
newCert = s_factory
.CreateApplicationCertificate(
m_applicationRecord.ApplicationUri!,
m_applicationRecord.ApplicationNames[0].Text!,
m_selfSignedServerCert.Subject + "1",
m_domainNames)
.SetECCurve(curve.Value)
.CreateForECDsa();
}
// RSA Certificate
else
{
newCert = s_factory
.CreateApplicationCertificate(
m_applicationRecord.ApplicationUri!,
m_applicationRecord.ApplicationNames[0].Text!,
m_selfSignedServerCert.Subject + "1",
m_domainNames)
.CreateForRSA();
}
using (newCert)
{
byte[]? privateKey = null;
if (keyFormat == "PFX")
{
Assert.That(newCert.HasPrivateKey, Is.True);
privateKey = newCert.Export(X509ContentType.Pfx);
}
else if (keyFormat == "PEM")
{
Assert.That(newCert.HasPrivateKey, Is.True);
privateKey = PEMWriter.ExportPrivateKeyAsPEM(newCert);
}
else
{
Assert.Fail($"Testing unsupported key format {keyFormat}.");
}
bool success = await m_pushClient.PushClient.UpdateCertificateAsync(
m_pushClient.PushClient.DefaultApplicationGroup,
m_certificateType,
newCert.RawData.ToByteString(),
keyFormat,
privateKey.ToByteString(),
default).ConfigureAwait(false);
if (success)
{
await ApplyChangesIgnoreChannelTearDownAsync().ConfigureAwait(false);
}
await VerifyNewPushServerCertAsync(newCert.RawData.ToByteString()).ConfigureAwait(false);
}
}
[Test]
[Order(540)]
public async Task UpdateCertificateNewKeyPairPFXAsync()
{
await UpdateCertificateWithNewKeyPairAsync("PFX").ConfigureAwait(false);
}
[Test]
[Order(550)]
public async Task UpdateCertificateNewKeyPairPEMAsync()
{
await UpdateCertificateWithNewKeyPairAsync("PEM").ConfigureAwait(false);
}
public async Task UpdateCertificateWithNewKeyPairAsync(string keyFormat)
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
ArrayOf<string> keyFormats = await m_pushClient.PushClient.GetSupportedKeyFormatsAsync().ConfigureAwait(false);
if (!keyFormats.Contains(keyFormat))
{
Assert
.Ignore($"Push server doesn't support {keyFormat} key update");
}
NodeId requestId = await m_gdsClient.GDSClient.StartNewKeyPairRequestAsync(
m_applicationRecord.ApplicationId,
default,
m_certificateType,
m_selfSignedServerCert.Subject + "3",
m_domainNames,
keyFormat,
null!).ConfigureAwait(false);
Assert.That(requestId.IsNull, Is.False);
ByteString privateKey = default;
ByteString certificate = default;
ArrayOf<ByteString> issuerCertificates = default;
DateTime now = DateTime.UtcNow;
do
{
try
{
Thread.Sleep(500);
(certificate, privateKey, issuerCertificates) = await m_gdsClient.GDSClient.FinishRequestAsync(
m_applicationRecord.ApplicationId,
requestId).ConfigureAwait(false);
}
catch (ServiceResultException sre)
{
// wait if GDS requires manual approval of cert request
if (sre.StatusCode == StatusCodes.BadNothingToDo &&
now.AddMinutes(5) > DateTime.UtcNow)
{
Thread.Sleep(10000);
}
else
{
throw;
}
}
} while (certificate.IsEmpty);
Assert.That(issuerCertificates.IsNull, Is.False);
Assert.That(privateKey.IsEmpty, Is.False);
await DisconnectGDSClientAsync().ConfigureAwait(false);
bool success = await m_pushClient.PushClient.UpdateCertificateAsync(
m_pushClient.PushClient.DefaultApplicationGroup,
m_certificateType,
certificate,
keyFormat,
privateKey,
issuerCertificates).ConfigureAwait(false);
if (success)
{
await ApplyChangesIgnoreChannelTearDownAsync().ConfigureAwait(false);
}
await VerifyNewPushServerCertAsync(certificate).ConfigureAwait(false);
}
[Test]
[Order(600)]
public async Task GetRejectedListAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
CertificateCollection collection = await m_pushClient.PushClient.GetRejectedListAsync().ConfigureAwait(false);
Assert.That(collection, Is.Not.Null);
}
[Test]
[Order(610)]
public async Task GetCertificatesAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
(ArrayOf<NodeId> certificateTypeIds, ArrayOf<ByteString> certificates) =
await m_pushClient.PushClient.GetCertificatesAsync(default).ConfigureAwait(false);
(ArrayOf<NodeId> expectedCertificateTypeIds, ArrayOf<ByteString> expectedCertificates) =
await m_pushClient.PushClient
.GetCertificatesAsync(m_pushClient.PushClient.DefaultApplicationGroup)
.ConfigureAwait(false);
Assert.That(certificateTypeIds.Count, Is.EqualTo(certificates.Count));
Assert.That(certificateTypeIds.ToList(), Is.EqualTo(expectedCertificateTypeIds.ToList()));
Assert.That(certificates.ToList(), Is.EqualTo(expectedCertificates.ToList()));
// GetCertificates returns parallel arrays: one certificate per
// certificate type the group is configured for (OPC 10000-12
// §7.8.4). Check every pair rather than whichever entry happens
// to come back first, and report the type that failed.
Assert.That(
certificates.Count,
Is.GreaterThan(0),
"The DefaultApplicationGroup must report at least one certificate.");
List<NodeId> returnedTypeIds = certificateTypeIds.ToList();
List<ByteString> returnedCertificates = certificates.ToList();
for (int i = 0; i < returnedCertificates.Count; i++)
{
Assert.That(
returnedCertificates[i].IsEmpty,
Is.False,
$"No certificate was returned for type {returnedTypeIds[i]}.");
using Certificate x509 = Certificate.FromRawData(returnedCertificates[i]);
Assert.That(
x509,
Is.Not.Null,
$"The certificate for type {returnedTypeIds[i]} did not parse.");
}
}
/// <summary>
/// OPC 10000-12 §7.10.2: calling ApplyChanges with no active,
/// staged transaction returns Bad_NothingToDo rather than
/// silently succeeding. Every preceding push-management test in
/// this fixture already commits (or cancels) its own transaction,
/// so by the time this test runs there is nothing pending.
/// </summary>
[Test]
[Order(700)]
public async Task ApplyChangesAsync()
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(
() => m_pushClient.PushClient.ApplyChangesAsync().AsTask());
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadNothingToDo));
}
/// <summary>
/// Regression for OPC Foundation/UA-.NETStandard issue #3562.
/// After a successful <c>UpdateCertificate</c> + <c>ApplyChanges</c>
/// the server MUST force existing SecureChannels that were
/// negotiated against the rotated certificate to renegotiate per
/// OPC UA Part 12 §7.10.9 — the response is returned first, then
/// the channel is cut so the next service call over the stale
/// channel fails and the client's reconnect logic transfers the
/// session over a fresh SecureChannel.
/// </summary>
[Test]
[Order(701)]
public async Task ApplyChangesForcesChannelRenegotiateAsync()
{
if (m_certificateType != OpcUa.ObjectTypeIds.RsaSha256ApplicationCertificateType)
{
Assert.Ignore("Test only supported for RSA");
}
await ConnectPushClientAsync(true).ConfigureAwait(false);
using var serverCert = Certificate.FromRawData(
m_pushClient.PushClient.Session!.ConfiguredEndpoint.Description.ServerCertificate);
if (!X509Utils.CompareDistinguishedName(serverCert.Subject, serverCert.Issuer))
{
Assert.Ignore("Server has no self signed cert in use.");
}
bool success = await m_pushClient.PushClient.UpdateCertificateAsync(
default,
m_certificateType,
serverCert.RawData.ToByteString(),
null!,
default,
default).ConfigureAwait(false);
if (!success)
{
Assert.Ignore("UpdateCertificate refused — nothing to assert.");
}
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
// Wait for the server-side deferred apply (250 ms grace +
// generous safety margin for CI) to complete its channel
// teardown. The fix for #3562 schedules this fan-out via
// ConfigurationNodeManager.ScheduleDeferredApplyChanges.
await Task.Delay(TimeSpan.FromMilliseconds(1500)).ConfigureAwait(false);
// The stale session is bound to a SecureChannel whose
// ServerCertificate matches the rotated cert. The next
// service call MUST surface a channel-invalidation error
// (BadSecureChannelIdInvalid / BadSecureChannelClosed /
// BadServerHalted / BadCertificateInvalid). A clean response
// would indicate the server failed to renegotiate.
ServiceResultException? expected = null;
try
{
_ = await m_pushClient.PushClient.GetRejectedListAsync(default)
.ConfigureAwait(false);
}
catch (ServiceResultException sre)
{
expected = sre;
}
catch (AggregateException agg) when (agg.InnerException is ServiceResultException sre)
{
expected = sre;
}
catch (Exception ex)
{
TestContext.Out.WriteLine(
$"Unexpected exception on stale channel: {ex.GetType().Name}: {ex.Message}");
}
Assert.That(expected, Is.Not.Null,
"Service call on the stale channel was expected to fail after ApplyChanges.");
uint status = expected.StatusCode.Code;
Assert.That(
status == StatusCodes.BadSecureChannelIdInvalid ||
status == StatusCodes.BadSecureChannelClosed ||
status == StatusCodes.BadServerHalted ||
status == StatusCodes.BadCertificateInvalid ||
status == StatusCodes.BadCommunicationError ||
status == StatusCodes.BadConnectionClosed ||
status == StatusCodes.BadNotConnected ||
status == StatusCodes.BadRequestInterrupted ||
status == StatusCodes.BadRequestTimeout ||
status == StatusCodes.BadNoCommunication,
$"Unexpected status {expected.StatusCode} from stale channel after ApplyChanges.");
// A fresh connect to the same endpoint must succeed —
// listener socket stays bound, only the channel is cut.
await VerifyNewPushServerCertAsync(serverCert.RawData.ToByteString())
.ConfigureAwait(false);
}
/// <summary>
/// End-to-end enforcement of a pushed CRL against a connected client
/// (OPC UA Part 12 §7.10.9): a client whose CA-issued application
/// certificate is revoked by a CRL committed through
/// <c>CloseAndUpdate</c> + <c>ApplyChanges</c> must lose its
/// SecureChannel shortly after the response — without waiting for
/// its security-token renewal — and must not be able to reconnect.
/// </summary>
[Test]
[Order(702)]
public async Task PushedCrlDisconnectsRevokedClientAsync()
{
if (m_certificateType != OpcUa.ObjectTypeIds.RsaSha256ApplicationCertificateType)
{
Assert.Ignore("Test only supported for RSA");
}
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
const string victimAppUri = "urn:localhost:opcfoundation.org:CrlPushVictim";
const string victimSubject = "CN=CRL Push Victim, O=OPC Foundation";
using Certificate victimCa = CertificateBuilder
.Create("CN=CRL Push Victim CA, O=OPC Foundation")
.SetCAConstraint()
.SetRSAKeySize(2048)
.CreateForRSA();
X509CRL emptyCaCrl = DefaultCertificateIssuer.Instance.RevokeCertificates(
victimCa, null!, null!);
using Certificate victimCert = CertificateBuilder
.Create(victimSubject)
.AddExtension(new X509SubjectAltNameExtension(
victimAppUri,
new[] { Utils.GetHostName() }))
.SetIssuer(victimCa)
.SetRSAKeySize(2048)
.CreateForRSA();
// 1. Trust the CA (with its fresh, empty CRL) on the push server.
await ConnectPushClientAsync(true).ConfigureAwait(false);
TrustListDataType originalTrustList = await m_pushClient.PushClient
.ReadTrustListAsync().ConfigureAwait(false);
originalTrustList.SpecifiedLists = (uint)TrustListMasks.All;
ServerPushConfigurationClient? victim = null;
ApplicationInstance? victimApp = null;
try
{
TrustListDataType withCa = await m_pushClient.PushClient
.ReadTrustListAsync().ConfigureAwait(false);
withCa.SpecifiedLists = (uint)TrustListMasks.All;
ArrayOf<ByteString> addedTrustedCerts = new ByteString[]
{
victimCa.RawData.ToByteString()
};
ArrayOf<ByteString> addedTrustedCrls = new ByteString[]
{
emptyCaCrl.RawData.ToByteString()
};
withCa.TrustedCertificates = withCa.TrustedCertificates.AddItems(addedTrustedCerts);
withCa.TrustedCrls = withCa.TrustedCrls.AddItems(addedTrustedCrls);
Assert.That(
await m_pushClient.PushClient.UpdateTrustListAsync(withCa).ConfigureAwait(false),
Is.True);
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
await Task.Delay(TimeSpan.FromMilliseconds(1500)).ConfigureAwait(false);
// 2. Build the victim client whose application certificate is
// issued by the now-trusted CA.
string victimPkiRoot = Path.Combine(
Path.GetTempPath(),
"OPC",
"pki-victim-" + Guid.NewGuid().ToString("N")[..8]);
victimApp = new ApplicationInstance(telemetry)
{
ApplicationName = "CRL Push Victim",
ApplicationType = ApplicationType.Client,
ConfigSectionName = "Opc.Ua.CrlPushVictim"
};
ArrayOf<CertificateIdentifier> victimCertIds =
Configuration.ApplicationConfigurationBuilder.CreateDefaultApplicationCertificates(
victimSubject,
CertificateStoreType.Directory,
victimPkiRoot);
ApplicationConfiguration victimConfig = await victimApp
.Build(victimAppUri, "http://opcfoundation.org/UA/CrlPushVictim")
.AsClient()
.AddSecurityConfiguration(victimCertIds, victimPkiRoot, victimPkiRoot)
.SetAutoAcceptUntrustedCertificates(true)
// The victim holds no CRL for the CA that issued the push server's
// current certificate; this test is about the server enforcing the
// pushed CRL, so the victim tolerates an unknown server revocation
// status (OPC 10000-4 6.1.3: Find Revocation List is suppressible).
.SetRejectUnknownRevocationStatus(false)
.SetRejectSHA1SignedCertificates(false)
.SetMinimumCertificateKeySize(1024)
.CreateAsync()
.ConfigureAwait(false);
// Seed the victim's own store with the CA-issued certificate
// (and its issuer chain) BEFORE the instance check, so the
// check adopts it instead of creating a self-signed one.
CertificateIdentifier victimId =
victimConfig.SecurityConfiguration.ApplicationCertificate!;
var ownStoreId = new CertificateStoreIdentifier(victimId!.StorePath!, noPrivateKeys: false);
using (ICertificateStore ownStore = ownStoreId.OpenStore(telemetry))
{
await ownStore.AddAsync(victimCert).ConfigureAwait(false);
}
var victimIssuerStoreId = new CertificateStoreIdentifier(
victimConfig.SecurityConfiguration.TrustedIssuerCertificates.StorePath!);
using (ICertificateStore issuerStore = victimIssuerStoreId.OpenStore(telemetry))
{
using Certificate victimCaPublic = Certificate.FromRawData(victimCa.RawData);
await issuerStore.AddAsync(victimCaPublic).ConfigureAwait(false);
await issuerStore.AddCRLAsync(emptyCaCrl).ConfigureAwait(false);
}
Assert.That(
await victimApp.CheckApplicationInstanceCertificatesAsync(true).ConfigureAwait(false),
Is.True);
victim = new ServerPushConfigurationClient(victimConfig)
{
AdminCredentials = m_pushClient.SysAdminUser,
Endpoint = new ConfiguredEndpoint(
null,
m_pushClient.PushClient.Endpoint!.Description,
EndpointConfiguration.Create(victimConfig))
};
await victim.ConnectAsync().ConfigureAwait(false);
// Sanity: the CA-issued victim is connected and can talk to
// the server before the revocation.
_ = await victim.GetSupportedKeyFormatsAsync().ConfigureAwait(false);
// 3. Push the CRL revoking the victim and apply it. The CRL
// set replaces the CA's empty CRL with the revoking one.
using var revoked = new CertificateCollection { victimCert };
X509CRL revokingCrl = DefaultCertificateIssuer.Instance.RevokeCertificates(
victimCa,
new X509CRLCollection { emptyCaCrl },
revoked);
TrustListDataType withRevocation = await m_pushClient.PushClient
.ReadTrustListAsync().ConfigureAwait(false);
withRevocation.SpecifiedLists = (uint)TrustListMasks.All;
ByteString emptyCaCrlBlob = emptyCaCrl.RawData.ToByteString();
var newCrls = new List<ByteString>();
foreach (ByteString crlBlob in withRevocation.TrustedCrls)
{
if (crlBlob != emptyCaCrlBlob)
{
newCrls.Add(crlBlob);
}
}
newCrls.Add(revokingCrl.RawData.ToByteString());
withRevocation.TrustedCrls = newCrls.ToArrayOf();
Assert.That(
await m_pushClient.PushClient.UpdateTrustListAsync(withRevocation)
.ConfigureAwait(false),
Is.True);
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
// Wait for the server-side deferred apply (250 ms grace +
// generous safety margin for CI) to complete the channel cut.
await Task.Delay(TimeSpan.FromMilliseconds(1500)).ConfigureAwait(false);
// 4. The victim's channel must have been cut, and its
// reconnect attempts must fail because its certificate is
// revoked — so the next call cannot succeed.
Exception? failure = null;
try
{
_ = await victim.GetSupportedKeyFormatsAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
failure = ex;
}
Assert.That(failure, Is.Not.Null,
"The revoked client was expected to lose its connection after the CRL push.");
TestContext.Out.WriteLine(
$"Revoked victim client failed as expected: {failure.GetType().Name}: {failure.Message}");
// A fresh reconnect must be rejected too: the next
// OpenSecureChannel handshake re-validates the (now revoked)
// client certificate against the pushed CRL.
try
{
await victim.DisconnectAsync().ConfigureAwait(false);
}
catch
{
// The dead session cannot be closed cleanly; only the
// local state reset matters before the reconnect attempt.
}
Exception? reconnectFailure = null;
try
{
await victim.ConnectAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
reconnectFailure = ex;
}
Assert.That(reconnectFailure, Is.Not.Null,
"A fresh connect of the revoked client was expected to be rejected.");
TestContext.Out.WriteLine(
$"Revoked victim reconnect rejected as expected: " +
$"{reconnectFailure.GetType().Name}: {reconnectFailure.Message}");
// The unaffected admin client must still be able to connect.
await ConnectPushClientAsync(true).ConfigureAwait(false);
}
finally
{
// CA1508 misjudges these null checks: the try block can throw
// before either local is assigned.
#pragma warning disable CA1508
if (victim != null)
{
try
{
await victim.DisconnectAsync().ConfigureAwait(false);
}
catch
{
// The victim's channel is expected to be dead.
}
await victim.DisposeAsync().ConfigureAwait(false);
}
if (victimApp != null)
{
// Let any in-flight reconnect attempt of the disposed
// client wind down before its certificate manager (and
// with it the cached validation stores) is disposed, so
// a post-disposal validation cannot re-create validator
// state that nothing would release.
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
await victimApp.DisposeAsync().ConfigureAwait(false);
}
#pragma warning restore CA1508
// Restore the original trust material for subsequent tests.
try
{
await ConnectPushClientAsync(true).ConfigureAwait(false);
if (await m_pushClient.PushClient.UpdateTrustListAsync(originalTrustList)
.ConfigureAwait(false))
{
await m_pushClient.PushClient.ApplyChangesAsync().ConfigureAwait(false);
// Let the server-side deferred apply (grace period +
// §7.10.9 sweep) finish before the fixture may tear
// the server down, so the sweep never races the
// certificate manager's disposal.
await Task.Delay(TimeSpan.FromMilliseconds(1500)).ConfigureAwait(false);
}
}
catch (Exception ex)
{
TestContext.Out.WriteLine($"Trust list restore failed: {ex.Message}");
}
}
}
[Test]
[Order(800)]
public async Task VerifyNoUserAccessAsync()
{
await ConnectPushClientAsync(false).ConfigureAwait(false);
await Assert.ThatAsync(() => m_pushClient.PushClient.ApplyChangesAsync().AsTask(), Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(() => m_pushClient.PushClient.GetRejectedListAsync().AsTask(), Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() => m_pushClient.PushClient.GetCertificatesAsync(default).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() =>
m_pushClient.PushClient.UpdateCertificateAsync(
default,
default,
m_selfSignedServerCert.RawData.ToByteString(),
null!,
default,
default
).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert.ThatAsync(
() => m_pushClient.PushClient.CreateSigningRequestAsync(default, default, null!, false, default).AsTask(),
Throws.Exception).ConfigureAwait(false);
await Assert
.ThatAsync(() => m_pushClient.PushClient.ReadTrustListAsync().AsTask(), Throws.Exception).ConfigureAwait(false);
}
private async Task ConnectPushClientAsync(
bool sysAdmin,
[System.Runtime.CompilerServices.CallerMemberName] string memberName = "")
{
int retryCount = 3;
var retryInterval = TimeSpan.FromSeconds(2);
while (retryCount > 0)
{
try
{
m_pushClient.PushClient.AdminCredentials = sysAdmin
? m_pushClient.SysAdminUser
: m_pushClient.AppUser;
await m_pushClient.ConnectAsync(m_securityPolicyUri).ConfigureAwait(false);
TestContext.Progress.WriteLine($"GDS Push({sysAdmin}) Connected -- {memberName}");
return; // Connection successful, exit the loop
}
catch (Exception ex)
{
TestContext.Progress.WriteLine($"Connection attempt failed: {ex.Message}. Retrying in {retryInterval}...");
retryCount--;
if (retryCount == 0)
{
TestContext.Progress.WriteLine($"GDS Push({sysAdmin}) Connection failed after multiple retries -- {memberName}");
throw; // Re-throw the exception if all retries failed
}
await Task.Delay(retryInterval).ConfigureAwait(false);
}
}
TestContext.Progress.WriteLine($"GDS Push({sysAdmin}) Connected -- {memberName}");
}
private async Task DisconnectPushClientAsync()
{
await m_pushClient.PushClient.DisconnectAsync().ConfigureAwait(false);
}
private async Task ConnectGDSClientAsync(
bool admin,
[System.Runtime.CompilerServices.CallerMemberName] string memberName = "")
{
m_gdsClient.GDSClient.AdminCredentials = admin
? m_gdsClient.AdminUser
: m_gdsClient.AppUser;
await m_gdsClient.GDSClient.ConnectAsync(m_gdsClient.GDSClient.EndpointUrl!)
.ConfigureAwait(false);
TestContext.Progress.WriteLine($"GDS Client({admin}) connected -- {memberName}");
}
private async Task DisconnectGDSClientAsync()
{
await m_gdsClient.GDSClient.DisconnectAsync().ConfigureAwait(false);
}
private async Task RegisterPushServerApplicationAsync(
string discoveryUrl,
ITelemetryContext telemetry,
CancellationToken ct = default)
{
if (m_applicationRecord == null && discoveryUrl != null)
{
EndpointDescription endpointDescription = (await CoreClientUtils.SelectEndpointAsync(
m_gdsClient.Configuration,
discoveryUrl,
true,
telemetry,
ct).ConfigureAwait(false))!;
ApplicationDescription description = endpointDescription!.Server;
m_applicationRecord = new ApplicationRecordDataType
{
ApplicationNames = [description.ApplicationName],
ApplicationUri = description.ApplicationUri,
ApplicationType = description.ApplicationType,
ProductUri = description.ProductUri,
DiscoveryUrls = description.DiscoveryUrls,
ServerCapabilities = ["NA"]
};
}
Assert.That(m_applicationRecord, Is.Not.Null);
Assert.That(m_applicationRecord.ApplicationId.IsNull, Is.True);
NodeId id = await m_gdsClient.GDSClient.RegisterApplicationAsync(m_applicationRecord, ct).ConfigureAwait(false);
Assert.That(id.IsNull, Is.False);
m_applicationRecord.ApplicationId = id;
// add issuer and trusted certs to client stores
NodeId trustListId = await m_gdsClient.GDSClient.GetTrustListAsync(id, default, ct).ConfigureAwait(false);
TrustListDataType trustList = await m_gdsClient.GDSClient.ReadTrustListAsync(
trustListId,
0,
ct).ConfigureAwait(false);
bool result = await AddTrustListToStoreAsync(
m_gdsClient.Configuration.SecurityConfiguration,
trustList,
telemetry).ConfigureAwait(false);
Assert.That(result, Is.True);
result = await AddTrustListToStoreAsync(
m_pushClient.Config.SecurityConfiguration,
trustList,
telemetry).ConfigureAwait(false);
Assert.That(result, Is.True);
}
private async Task UnRegisterPushServerApplicationAsync()
{
await m_gdsClient.GDSClient.UnregisterApplicationAsync(m_applicationRecord.ApplicationId).ConfigureAwait(false);
m_applicationRecord.ApplicationId = default;
}
/// <summary>
/// Calls ApplyChanges on the push client. After the fix for
/// issue #3562 the server schedules channel teardown to run
/// AFTER the method response has been flushed (see
/// <c>ConfigurationNodeManager.ScheduleDeferredApplyChanges</c>),
/// so the happy path is for this call to return cleanly. Any
/// transport-level failure is still tolerated for resilience
/// because:
/// <list type="bullet">
/// <item>HTTPS rebinds the Kestrel listener and may interrupt
/// the response under load.</item>
/// <item>The TCP grace period (~250 ms) is not a hard guarantee
/// on slow CI runners.</item>
/// </list>
/// The caller's verification step
/// (<see cref="VerifyNewPushServerCertAsync"/>) asserts the new
/// certificate is actually in effect on a fresh connection.
/// </summary>
private async Task ApplyChangesIgnoreChannelTearDownAsync()
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await m_pushClient.PushClient.ApplyChangesAsync(cts.Token).ConfigureAwait(false);
// Per OPC UA Part 12 §7.10.9 the server defers the
// SecureChannel renegotiation until the ApplyChanges
// response has been delivered. Wait long enough for the
// server-side deferred apply (250 ms grace + safety
// margin) so the next reconnect attempt does not race
// it.
await Task.Delay(TimeSpan.FromMilliseconds(1500)).ConfigureAwait(false);
}
catch (Exception ex)
{
// Tolerated transport failures (see XML doc above):
// ServiceResultException with BadRequestTimeout,
// BadRequestInterrupted, BadSecureChannelClosed, ...
// OperationCanceledException from the bounded CTS,
// wrapping AggregateException.
TestContext.Out.WriteLine(
$"ApplyChangesAsync tolerated teardown: {ex.GetType().Name}: {ex.Message}");
}
}
private async Task VerifyNewPushServerCertAsync(ByteString certificateBlob)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
ILogger logger = telemetry.CreateLogger<PushTest>();
await DisconnectPushClientAsync().ConfigureAwait(false);
const int maxWaitSeconds = 10;
const int retryIntervalMs = 2000;
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed.TotalSeconds < maxWaitSeconds)
{
try
{
await m_gdsClient.GDSClient.ConnectAsync(m_gdsClient.GDSClient.EndpointUrl!).ConfigureAwait(false);
await m_pushClient.ConnectAsync(m_securityPolicyUri).ConfigureAwait(false);
using Certificate serverCertificate = Utils.ParseCertificateBlob(
m_pushClient.PushClient.Session!.ConfiguredEndpoint.Description.ServerCertificate,
telemetry);
if (ByteString.From(serverCertificate.RawData) == certificateBlob)
{
// Success, exit early
return;
}
await DisconnectPushClientAsync().ConfigureAwait(false);
await Task.Delay(retryIntervalMs).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Failure while verifying new Push Server certificate.");
}
}
Assert.Fail("Server certificate did not match with the Certificate pushed by " +
"the GDS within the allowed time.");
}
private static async Task<bool> AddTrustListToStoreAsync(
SecurityConfiguration config,
TrustListDataType trustList,
ITelemetryContext telemetry)
{
int masks = (int)trustList.SpecifiedLists;
CertificateCollection? issuerCertificates = null;
X509CRLCollection? issuerCrls = null;
CertificateCollection? trustedCertificates = null;
X509CRLCollection? trustedCrls = null;
try
{
// test integrity of all CRLs
if ((masks & (int)TrustListMasks.IssuerCertificates) != 0)
{
issuerCertificates = [];
foreach (ByteString cert in trustList.IssuerCertificates)
{
using var parsed = Certificate.FromRawData(cert.ToArray());
issuerCertificates.Add(parsed);
}
}
if ((masks & (int)TrustListMasks.IssuerCrls) != 0)
{
issuerCrls = [];
foreach (ByteString crl in trustList.IssuerCrls)
{
issuerCrls.Add(new X509CRL(crl.ToArray()));
}
}
if ((masks & (int)TrustListMasks.TrustedCertificates) != 0)
{
trustedCertificates = [];
foreach (ByteString cert in trustList.TrustedCertificates)
{
using var parsed = Certificate.FromRawData(cert.ToArray());
trustedCertificates.Add(parsed);
}
}
if ((masks & (int)TrustListMasks.TrustedCrls) != 0)
{
trustedCrls = [];
foreach (ByteString crl in trustList.TrustedCrls)
{
trustedCrls.Add(new X509CRL(crl.ToArray()));
}
}
// update store
// test integrity of all CRLs
int updateMasks = (int)TrustListMasks.None;
if ((masks & (int)TrustListMasks.IssuerCertificates) != 0 &&
await UpdateStoreCertificatesAsync(
config.TrustedIssuerCertificates,
issuerCertificates!,
telemetry)
.ConfigureAwait(false))
{
updateMasks |= (int)TrustListMasks.IssuerCertificates;
}
if ((masks & (int)TrustListMasks.IssuerCrls) != 0 &&
await UpdateStoreCrlsAsync(
config.TrustedIssuerCertificates,
issuerCrls!,
telemetry)
.ConfigureAwait(false))
{
updateMasks |= (int)TrustListMasks.IssuerCrls;
}
if ((masks & (int)TrustListMasks.TrustedCertificates) != 0 &&
await UpdateStoreCertificatesAsync(
config.TrustedPeerCertificates,
trustedCertificates!,
telemetry)
.ConfigureAwait(false))
{
updateMasks |= (int)TrustListMasks.TrustedCertificates;
}
if ((masks & (int)TrustListMasks.TrustedCrls) != 0 &&
await UpdateStoreCrlsAsync(
config.TrustedPeerCertificates,
trustedCrls!,
telemetry)
.ConfigureAwait(false))
{
updateMasks |= (int)TrustListMasks.TrustedCrls;
}
return masks == updateMasks;
}
finally
{
issuerCertificates?.Dispose();
trustedCertificates?.Dispose();
}
}
private static async Task<bool> UpdateStoreCrlsAsync(
CertificateTrustList trustList,
X509CRLCollection updatedCrls,
ITelemetryContext telemetry)
{
bool result = true;
try
{
using ICertificateStore store = trustList.OpenStore(telemetry);
X509CRLCollection storeCrls = await store.EnumerateCRLsAsync()
.ConfigureAwait(false);
foreach (X509CRL crl in storeCrls)
{
if (!updatedCrls.Remove(crl) &&
!await store.DeleteCRLAsync(crl).ConfigureAwait(false))
{
result = false;
}
}
foreach (X509CRL crl in updatedCrls)
{
await store.AddCRLAsync(crl).ConfigureAwait(false);
}
}
catch
{
result = false;
}
return result;
}
private static async Task<bool> UpdateStoreCertificatesAsync(
CertificateTrustList trustList,
CertificateCollection updatedCerts,
ITelemetryContext telemetry)
{
bool result = true;
try
{
using ICertificateStore store = trustList.OpenStore(telemetry);
using CertificateCollection storeCerts = await store.EnumerateAsync()
.ConfigureAwait(false);
foreach (Certificate cert in storeCerts)
{
// CA1868: Contains() then Remove() is intentional — different branches
// perform different actions (delete from store vs. remove from working list).
#pragma warning disable CA1868
if (!updatedCerts.Contains(cert))
{
if (!store.DeleteAsync(cert.Thumbprint).Result)
{
result = false;
}
}
else
{
updatedCerts.Remove(cert);
}
#pragma warning restore CA1868
}
foreach (Certificate cert in updatedCerts)
{
await store.AddAsync(cert).ConfigureAwait(false);
}
}
catch
{
result = false;
}
return result;
}
/// <summary>
/// Create CA test certificates.
/// </summary>
private async Task CreateCATestCertsAsync(string tempStorePath, ITelemetryContext telemetry)
{
var certificateStoreIdentifier = new CertificateStoreIdentifier(tempStorePath, false);
Assert.That(EraseStore(certificateStoreIdentifier, telemetry), Is.True);
const string subjectName = "CN=CA Test Cert, O=OPC Foundation";
ECCurve? curve = CryptoUtils.GetCurveFromCertificateTypeId(m_certificateType);
if (curve != null)
{
m_caCert = await s_factory
.CreateCertificate(subjectName)
.SetCAConstraint()
.SetECCurve(curve.Value)
.CreateForECDsa()
.AddToStoreAsync(certificateStoreIdentifier, telemetry: telemetry)
.ConfigureAwait(false);
}
// RSA Certificate
else
{
m_caCert = await s_factory
.CreateCertificate(subjectName)
.SetCAConstraint()
.CreateForRSA()
.AddToStoreAsync(certificateStoreIdentifier, telemetry: telemetry)
.ConfigureAwait(false);
}
// initialize cert revocation list (CRL)
X509CRL caCrl = await CertificateGroup
.LoadCrlCreateEmptyIfNonExistantAsync(m_caCert, certificateStoreIdentifier, telemetry)
.ConfigureAwait(false);
Assert.That(caCrl, Is.Not.Null);
}
private static bool EraseStore(CertificateStoreIdentifier storeIdentifier, ITelemetryContext telemetry)
{
bool result = true;
try
{
using ICertificateStore store = storeIdentifier.OpenStore(telemetry);
using CertificateCollection certs = store.EnumerateAsync().Result;
foreach (Certificate cert in certs)
{
if (!store.DeleteAsync(cert.Thumbprint).Result)
{
result = false;
}
}
foreach (X509CRL crl in store.EnumerateCRLsAsync().Result)
{
if (!store.DeleteCRLAsync(crl).Result)
{
result = false;
}
}
}
catch
{
result = false;
}
return result;
}
private async Task<List<ReferenceDescription>> FindChildrenByTypeDefinitionRecursiveAsync(
NodeId parentNodeId,
NodeId targetTypeDefinitionId,
CancellationToken ct = default)
{
var browseDescription = new BrowseDescription
{
NodeId = parentNodeId,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)NodeClass.Object,
ResultMask = (uint)BrowseResultMask.All
};
BrowseResponse results = await m_pushClient.PushClient.Session!.BrowseAsync(
null, null, 0, [browseDescription], ct).ConfigureAwait(false);
ILookup<bool, ReferenceDescription> references = results.Results.ToList()
.Where(r => StatusCode.IsGood(r.StatusCode))
.SelectMany(r => r.References.ToList())
.ToLookup(r => r.TypeDefinition == targetTypeDefinitionId);
var allResults = new List<ReferenceDescription>(references[true]);
foreach (ReferenceDescription nonMatch in references[false])
{
ct.ThrowIfCancellationRequested();
var childNodeId = ExpandedNodeId.ToNodeId(nonMatch.NodeId, m_pushClient.PushClient.Session.NamespaceUris);
List<ReferenceDescription> childResults =
await FindChildrenByTypeDefinitionRecursiveAsync(childNodeId, targetTypeDefinitionId, ct).ConfigureAwait(false);
allResults.AddRange(childResults);
}
return allResults;
}
private const int kRandomStart = 1;
/// <summary>
/// Id of the extra certificate group this fixture configures on the
/// GDS, and the browse name its group node carries.
/// </summary>
private const string kCustomGroupId = "MyCustomGroup";
private RandomSource m_randomSource;
private ITelemetryContext m_telemetry;
private GlobalDiscoveryTestServer m_server;
private GlobalDiscoveryTestClient m_gdsClient;
private ServerConfigurationPushTestClient m_pushClient;
private ApplicationRecordDataType m_applicationRecord = null!;
private Certificate m_selfSignedServerCert;
private string[] m_domainNames;
private Certificate m_caCert = null!;
private readonly string m_certificateTypeString;
private readonly NodeId m_certificateType;
private readonly string m_securityPolicyUri;
}
}