mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 17:16:16 +08:00
9b034f8291
# 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>
2180 lines
99 KiB
C#
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;
|
|
}
|
|
}
|