mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 00:56:28 +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>
1056 lines
37 KiB
C#
1056 lines
37 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.Security.Cryptography;
|
||
using System.Security.Cryptography.X509Certificates;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using NUnit.Framework;
|
||
using Opc.Ua.Client;
|
||
using Opc.Ua.Client.TestFramework;
|
||
|
||
namespace Opc.Ua.Core.Security.Tests
|
||
{
|
||
/// <summary>
|
||
/// compliance tests for Security – endpoint security
|
||
/// policies, user tokens, and secure connections.
|
||
/// </summary>
|
||
[TestFixture]
|
||
[Category("Conformance")]
|
||
[Category("Security")]
|
||
public class SecurityTests : TestFixture
|
||
{
|
||
[Test]
|
||
public async Task VerifyServerAdvertisesSecureEndpointsAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints =
|
||
await GetEndpointsAsync().ConfigureAwait(false);
|
||
Assert.That(endpoints.Count, Is.GreaterThan(0));
|
||
|
||
bool hasSecure = false;
|
||
foreach (EndpointDescription e in endpoints)
|
||
{
|
||
if (e.SecurityMode != MessageSecurityMode.None)
|
||
{
|
||
hasSecure = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
Assert.That(hasSecure, Is.True,
|
||
"Server should advertise at least one secure endpoint.");
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifySecurityPolicyUriIsValidAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints =
|
||
await GetEndpointsAsync().ConfigureAwait(false);
|
||
Assert.That(endpoints.Count, Is.GreaterThan(0));
|
||
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode != MessageSecurityMode.None)
|
||
{
|
||
Assert.That(
|
||
ep.SecurityPolicyUri,
|
||
Is.Not.Null.And.Not.Empty);
|
||
Assert.That(
|
||
ep.SecurityPolicyUri,
|
||
Does.StartWith("http://opcfoundation.org/UA/"));
|
||
}
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyAnonymousUserTokenOnEndpointAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints =
|
||
await GetEndpointsAsync().ConfigureAwait(false);
|
||
Assert.That(endpoints.Count, Is.GreaterThan(0));
|
||
|
||
bool hasAnonymous = false;
|
||
foreach (EndpointDescription e in endpoints)
|
||
{
|
||
if (e.UserIdentityTokens != default!)
|
||
{
|
||
foreach (UserTokenPolicy t in e.UserIdentityTokens)
|
||
{
|
||
if (t.TokenType == UserTokenType.Anonymous)
|
||
{
|
||
hasAnonymous = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasAnonymous)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
Assert.That(hasAnonymous, Is.True);
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyUsernameUserTokenOnEndpointAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints =
|
||
await GetEndpointsAsync().ConfigureAwait(false);
|
||
Assert.That(endpoints.Count, Is.GreaterThan(0));
|
||
|
||
bool hasUsername = false;
|
||
foreach (EndpointDescription e in endpoints)
|
||
{
|
||
if (e.UserIdentityTokens != default!)
|
||
{
|
||
foreach (UserTokenPolicy t in e.UserIdentityTokens)
|
||
{
|
||
if (t.TokenType == UserTokenType.UserName)
|
||
{
|
||
hasUsername = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasUsername)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!hasUsername)
|
||
{
|
||
Assert.Fail(
|
||
"Server does not advertise Username user token.");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public void ConnectWithSecurityModeNone()
|
||
{
|
||
Assert.That(Session.Connected, Is.True);
|
||
Assert.That(
|
||
Session.Endpoint.SecurityMode,
|
||
Is.EqualTo(MessageSecurityMode.None));
|
||
}
|
||
|
||
[Test]
|
||
public async Task ActivateWithAnonymousIdentityAsync()
|
||
{
|
||
ISession additionalSession = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(additionalSession.Connected, Is.True);
|
||
}
|
||
finally
|
||
{
|
||
await additionalSession.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
additionalSession.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public void SessionSecurityModeIsNone()
|
||
{
|
||
Assert.That(
|
||
Session.Endpoint.SecurityMode,
|
||
Is.EqualTo(MessageSecurityMode.None));
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyEndpointServerCertificateOnSecureEndpointAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints =
|
||
await GetEndpointsAsync().ConfigureAwait(false);
|
||
Assert.That(endpoints.Count, Is.GreaterThan(0));
|
||
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode != MessageSecurityMode.None)
|
||
{
|
||
Assert.That(
|
||
ep.ServerCertificate.Length,
|
||
Is.GreaterThan(0),
|
||
"Secure endpoint should have a server certificate.");
|
||
}
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyEndpointSecurityLevelOrderingAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints =
|
||
await GetEndpointsAsync().ConfigureAwait(false);
|
||
Assert.That(endpoints.Count, Is.GreaterThan(0));
|
||
|
||
bool hasPositiveLevel = false;
|
||
foreach (EndpointDescription e in endpoints)
|
||
{
|
||
if (e.SecurityMode != MessageSecurityMode.None &&
|
||
e.SecurityLevel > 0)
|
||
{
|
||
hasPositiveLevel = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
Assert.That(hasPositiveLevel, Is.True,
|
||
"At least one secure endpoint should have SecurityLevel > 0.");
|
||
}
|
||
|
||
[Test]
|
||
public async Task ConnectSecondSessionVerifyIndependentSecurityAsync()
|
||
{
|
||
ISession second = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(Session.Connected, Is.True);
|
||
Assert.That(second.Connected, Is.True);
|
||
Assert.That(
|
||
Session.SessionId,
|
||
Is.Not.EqualTo(second.SessionId));
|
||
}
|
||
finally
|
||
{
|
||
await second.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
second.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task ConnectWithEmptyUsernameReturnsBadIdentityTokenInvalidAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
EndpointDescription? usernameEp = null;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.UserIdentityTokens != default!)
|
||
{
|
||
foreach (UserTokenPolicy t in ep.UserIdentityTokens)
|
||
{
|
||
if (t.TokenType == UserTokenType.UserName)
|
||
{
|
||
usernameEp = ep;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (usernameEp != null)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (usernameEp == null)
|
||
{
|
||
Assert.Fail(
|
||
"Server does not advertise Username user token.");
|
||
}
|
||
|
||
try
|
||
{
|
||
ISession session = await OpenAuxSessionAsync(
|
||
userIdentity: new UserIdentity(string.Empty, ""u8))
|
||
.ConfigureAwait(false);
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
Assert.Fail("Expected ServiceResultException.");
|
||
}
|
||
catch (ServiceResultException sre)
|
||
{
|
||
Assert.That(
|
||
sre.StatusCode == StatusCodes.BadIdentityTokenInvalid ||
|
||
sre.StatusCode == StatusCodes.BadIdentityTokenRejected ||
|
||
sre.StatusCode == StatusCodes.BadUserAccessDenied,
|
||
Is.True,
|
||
$"Expected identity rejection, got {sre.StatusCode}");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task ConnectWithWrongPasswordReturnsBadIdentityTokenRejectedAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
bool hasUsername = false;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.UserIdentityTokens != default!)
|
||
{
|
||
foreach (UserTokenPolicy t in ep.UserIdentityTokens)
|
||
{
|
||
if (t.TokenType == UserTokenType.UserName)
|
||
{
|
||
hasUsername = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasUsername)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!hasUsername)
|
||
{
|
||
Assert.Fail(
|
||
"Server does not advertise Username user token.");
|
||
}
|
||
|
||
try
|
||
{
|
||
ISession session = await OpenAuxSessionAsync(
|
||
userIdentity: new UserIdentity("sysadmin", "WRONG_PASSWORD_12345"u8))
|
||
.ConfigureAwait(false);
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
Assert.Fail("Expected ServiceResultException.");
|
||
}
|
||
catch (ServiceResultException sre)
|
||
{
|
||
Assert.That(
|
||
sre.StatusCode == StatusCodes.BadIdentityTokenRejected ||
|
||
sre.StatusCode == StatusCodes.BadUserAccessDenied,
|
||
Is.True,
|
||
$"Expected rejection, got {sre.StatusCode}");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task ConnectWithSysadminCredentialsAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
bool hasUsername = false;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.UserIdentityTokens != default!)
|
||
{
|
||
foreach (UserTokenPolicy t in ep.UserIdentityTokens)
|
||
{
|
||
if (t.TokenType == UserTokenType.UserName)
|
||
{
|
||
hasUsername = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasUsername)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!hasUsername)
|
||
{
|
||
Assert.Fail(
|
||
"Server does not advertise Username user token.");
|
||
}
|
||
|
||
try
|
||
{
|
||
ISession session = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None,
|
||
userIdentity: new UserIdentity("sysadmin", "demo"u8))
|
||
.ConfigureAwait(false);
|
||
Assert.That(session.Connected, Is.True);
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
catch (ServiceResultException sre)
|
||
{
|
||
Assert.Fail(
|
||
$"Server rejected sysadmin credentials: {sre.StatusCode}");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task ConnectWithAppuserCredentialsAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
bool hasUsername = false;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.UserIdentityTokens != default!)
|
||
{
|
||
foreach (UserTokenPolicy t in ep.UserIdentityTokens)
|
||
{
|
||
if (t.TokenType == UserTokenType.UserName)
|
||
{
|
||
hasUsername = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasUsername)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!hasUsername)
|
||
{
|
||
Assert.Ignore(
|
||
"Server does not advertise Username user token.");
|
||
}
|
||
|
||
try
|
||
{
|
||
ISession session = await OpenAuxSessionAsync(
|
||
userIdentity: new UserIdentity("appuser", "demo"u8))
|
||
.ConfigureAwait(false);
|
||
Assert.That(session.Connected, Is.True);
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
catch (ServiceResultException sre)
|
||
{
|
||
Assert.Ignore(
|
||
$"Server rejected appuser credentials: {sre.StatusCode}");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task ConnectWithSpecialCharsUsernameAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
bool hasUsername = false;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.UserIdentityTokens != default!)
|
||
{
|
||
foreach (UserTokenPolicy t in ep.UserIdentityTokens)
|
||
{
|
||
if (t.TokenType == UserTokenType.UserName)
|
||
{
|
||
hasUsername = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasUsername)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!hasUsername)
|
||
{
|
||
Assert.Fail(
|
||
"Server does not advertise Username user token.");
|
||
}
|
||
|
||
// Expect rejection — the point is the server handles it
|
||
try
|
||
{
|
||
ISession session = await OpenAuxSessionAsync(
|
||
userIdentity: new UserIdentity("user@#$%^&*()", "demo"u8))
|
||
.ConfigureAwait(false);
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
catch (ServiceResultException)
|
||
{
|
||
// Any rejection is acceptable
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyEndpointListsUserIdentityTokenTypesAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
Assert.That(endpoints.Count, Is.GreaterThan(0));
|
||
|
||
bool anyHasTokens = false;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.UserIdentityTokens != default! &&
|
||
ep.UserIdentityTokens.Count > 0)
|
||
{
|
||
anyHasTokens = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
Assert.That(anyHasTokens, Is.True,
|
||
"At least one endpoint should support identity tokens.");
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifySecureEndpointsHaveNonEmptyServerCertificateAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode != MessageSecurityMode.None)
|
||
{
|
||
Assert.That(
|
||
ep.ServerCertificate, Is.Not.Null,
|
||
"Secure endpoint should have ServerCertificate.");
|
||
Assert.That(
|
||
ep.ServerCertificate.Length, Is.GreaterThan(0),
|
||
"Secure endpoint ServerCertificate should not be empty.");
|
||
}
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifySecurityPolicyUriStartsWithOpcFoundationAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
Assert.That(
|
||
ep.SecurityPolicyUri, Is.Not.Null.And.Not.Empty,
|
||
"SecurityPolicyUri should not be null or empty.");
|
||
Assert.That(
|
||
Uri.IsWellFormedUriString(
|
||
ep.SecurityPolicyUri, UriKind.Absolute),
|
||
Is.True,
|
||
$"SecurityPolicyUri '{ep.SecurityPolicyUri}' should be valid URI.");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifySecurityLevelHigherForMoreSecureAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
byte noneLevel = 0;
|
||
byte secureLevel = 0;
|
||
bool foundNone = false;
|
||
bool foundSecure = false;
|
||
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode == MessageSecurityMode.None && !foundNone)
|
||
{
|
||
noneLevel = ep.SecurityLevel;
|
||
foundNone = true;
|
||
}
|
||
else if (ep.SecurityMode != MessageSecurityMode.None &&
|
||
!foundSecure)
|
||
{
|
||
secureLevel = ep.SecurityLevel;
|
||
foundSecure = true;
|
||
}
|
||
}
|
||
|
||
if (foundNone && foundSecure)
|
||
{
|
||
Assert.That(secureLevel, Is.GreaterThanOrEqualTo(noneLevel),
|
||
"Secure endpoint SecurityLevel should be >= None.");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task ConnectWithSignSecurityModeAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
EndpointDescription? signEp = null;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode == MessageSecurityMode.Sign)
|
||
{
|
||
signEp = ep;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (signEp == null)
|
||
{
|
||
Assert.Fail("No Sign endpoint available.");
|
||
}
|
||
|
||
ISession session = await ClientFixture
|
||
.ConnectAsync(ServerUrl, signEp!.SecurityPolicyUri!)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task ConnectWithSignAndEncryptSecurityModeAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
EndpointDescription? encryptEp = null;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode == MessageSecurityMode.SignAndEncrypt)
|
||
{
|
||
encryptEp = ep;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (encryptEp == null)
|
||
{
|
||
Assert.Fail("No SignAndEncrypt endpoint available.");
|
||
}
|
||
|
||
ISession session = await ClientFixture
|
||
.ConnectAsync(ServerUrl, encryptEp!.SecurityPolicyUri!)
|
||
.ConfigureAwait(false);
|
||
try
|
||
{
|
||
Assert.That(session.Connected, Is.True);
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyMinimumKeyLengthOnCertificatesAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode != MessageSecurityMode.None &&
|
||
!ep.ServerCertificate.IsEmpty)
|
||
{
|
||
X509Certificate2 cert =
|
||
|
||
X509CertificateLoader.LoadCertificate(ep.ServerCertificate.ToArray());
|
||
int keySize;
|
||
using (RSA rsa = cert.GetRSAPublicKey()!)
|
||
using (ECDsa ecdsa = (rsa is null ? cert.GetECDsaPublicKey() : null)!)
|
||
{
|
||
keySize = rsa?.KeySize ?? ecdsa?.KeySize ?? 0;
|
||
}
|
||
Assert.That(keySize, Is.GreaterThanOrEqualTo(256),
|
||
"Server certificate key should be at least 256 bits.");
|
||
cert.Dispose();
|
||
}
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyNoneEndpointHasZeroSecurityLevelAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode == MessageSecurityMode.None)
|
||
{
|
||
Assert.That(ep.SecurityLevel, Is.Zero,
|
||
"None endpoint should have SecurityLevel=0.");
|
||
}
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyServerCertificateSubjectDNAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode != MessageSecurityMode.None &&
|
||
!ep.ServerCertificate.IsEmpty)
|
||
{
|
||
X509Certificate2 cert =
|
||
|
||
X509CertificateLoader.LoadCertificate(ep.ServerCertificate.ToArray());
|
||
Assert.That(cert.Subject, Is.Not.Null.And.Not.Empty,
|
||
"Secure endpoint certificate should have a valid Subject.");
|
||
cert.Dispose();
|
||
return;
|
||
}
|
||
}
|
||
|
||
Assert.Fail("No secure endpoint with certificate found.");
|
||
}
|
||
|
||
[Test]
|
||
public async Task ConnectWithSysadminWriteToNodeAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
bool hasUsername = false;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.UserIdentityTokens != default!)
|
||
{
|
||
foreach (UserTokenPolicy t in ep.UserIdentityTokens)
|
||
{
|
||
if (t.TokenType == UserTokenType.UserName)
|
||
{
|
||
hasUsername = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (hasUsername)
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!hasUsername)
|
||
{
|
||
Assert.Fail(
|
||
"Server does not advertise Username user token.");
|
||
}
|
||
|
||
ISession session;
|
||
try
|
||
{
|
||
session = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None,
|
||
userIdentity: new UserIdentity("sysadmin", "demo"u8))
|
||
.ConfigureAwait(false);
|
||
}
|
||
catch (ServiceResultException sre)
|
||
{
|
||
Assert.Fail(
|
||
$"Server rejected sysadmin credentials: {sre.StatusCode}");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
NodeId nodeId = ToNodeId(Constants.ScalarStaticInt32);
|
||
WriteResponse writeResp = await session.WriteAsync(
|
||
null,
|
||
new WriteValue[]
|
||
{
|
||
new() {
|
||
NodeId = nodeId,
|
||
AttributeId = Attributes.Value,
|
||
Value = new DataValue(Variant.From(42))
|
||
}
|
||
}.ToArrayOf(),
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
|
||
Assert.That(writeResp.Results.Count, Is.EqualTo(1));
|
||
Assert.That(
|
||
writeResp.Results[0].Code,
|
||
Is.EqualTo(StatusCodes.Good));
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true).ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifySecureEndpointHasUserTokenPoliciesAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
bool found = false;
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode != MessageSecurityMode.None)
|
||
{
|
||
Assert.That(
|
||
ep.UserIdentityTokens, Is.Not.Null,
|
||
"Secure endpoint should have UserIdentityTokens.");
|
||
Assert.That(
|
||
ep.UserIdentityTokens.Count, Is.GreaterThan(0),
|
||
"Secure endpoint should have at least one token policy.");
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!found)
|
||
{
|
||
Assert.Fail("No secure endpoint found.");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyEndpointApplicationUriMatchesServerAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
Assert.That(endpoints.Count, Is.GreaterThan(0));
|
||
|
||
string firstUri = endpoints[0].Server.ApplicationUri!;
|
||
Assert.That(firstUri, Is.Not.Null.And.Not.Empty);
|
||
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
Assert.That(ep.Server.ApplicationUri,
|
||
Is.EqualTo(firstUri),
|
||
"All endpoints should share the same ApplicationUri.");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyTransportProfileUriAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
Assert.That(ep.TransportProfileUri,
|
||
Is.Not.Null.And.Not.Empty,
|
||
"TransportProfileUri should be set on every endpoint.");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyEndpointServerDescriptionIsServerAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
Assert.That(
|
||
ep.Server.ApplicationType,
|
||
Is.EqualTo(ApplicationType.Server)
|
||
.Or.EqualTo(ApplicationType.ClientAndServer),
|
||
"Endpoint Server field should be Server or ClientAndServer.");
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task VerifyEndpointsHaveConsistentServerCertificateAsync()
|
||
{
|
||
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync().ConfigureAwait(false);
|
||
var certsByPolicy = new System.Collections.Generic.Dictionary<string, byte[]>();
|
||
|
||
foreach (EndpointDescription ep in endpoints)
|
||
{
|
||
if (ep.SecurityMode == MessageSecurityMode.None)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
string key = ep.SecurityPolicyUri!;
|
||
byte[] certBytes = ep.ServerCertificate.ToArray();
|
||
if (certsByPolicy.TryGetValue(key!, out byte[]? existing))
|
||
{
|
||
Assert.That(certBytes, Is.EqualTo(existing),
|
||
$"Endpoints with policy '{key}' should have same cert.");
|
||
}
|
||
else
|
||
{
|
||
certsByPolicy[key] = certBytes;
|
||
}
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task NoneSession001InsecureWithCertsAndNonces()
|
||
{
|
||
// Connect on an insecure channel (SecurityMode.None)
|
||
// and verify CreateSession / ActivateSession succeeds
|
||
// when both client certificate and nonces are present.
|
||
ISession session;
|
||
try
|
||
{
|
||
session = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
}
|
||
catch (ServiceResultException sre) when (sre.StatusCode == StatusCodes.BadUserAccessDenied &&
|
||
sre.Message.Contains("Too many failed authentication attempts", StringComparison.Ordinal))
|
||
{
|
||
Assert.Fail("Account locked out from prior negative tests; cannot authenticate");
|
||
return;
|
||
}
|
||
try
|
||
{
|
||
Assert.That(session.Connected, Is.True);
|
||
Assert.That(
|
||
session.Endpoint.SecurityMode,
|
||
Is.EqualTo(MessageSecurityMode.None));
|
||
|
||
// Server should have returned a session id and nonce
|
||
Assert.That(
|
||
session.SessionId, Is.Not.Null,
|
||
"Session must have a valid SessionId.");
|
||
Assert.That(
|
||
session.SessionId, Is.Not.EqualTo(NodeId.Null),
|
||
"Server should supply a valid SessionId.");
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task NoneSession002InsecureNoCerts()
|
||
{
|
||
// Connect on an insecure channel without a client
|
||
// certificate. The server should still accept the
|
||
// session when SecurityMode is None.
|
||
ISession session;
|
||
try
|
||
{
|
||
session = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
}
|
||
catch (ServiceResultException sre) when (sre.StatusCode == StatusCodes.BadUserAccessDenied &&
|
||
sre.Message.Contains("Too many failed authentication attempts", StringComparison.Ordinal))
|
||
{
|
||
Assert.Fail("Account locked out from prior negative tests; cannot authenticate");
|
||
return;
|
||
}
|
||
try
|
||
{
|
||
Assert.That(session.Connected, Is.True);
|
||
Assert.That(
|
||
session.Endpoint.SecurityMode,
|
||
Is.EqualTo(MessageSecurityMode.None));
|
||
Assert.That(
|
||
session.SessionId, Is.Not.Null,
|
||
"Session must have a valid SessionId.");
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task NoneSession003InsecureWithCertsNoNonce()
|
||
{
|
||
// Connect on an insecure channel with client certificate
|
||
// present but verify session works even when the server
|
||
// does not strictly require a nonce on None channels.
|
||
ISession session;
|
||
try
|
||
{
|
||
session = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
}
|
||
catch (ServiceResultException sre) when (sre.StatusCode == StatusCodes.BadUserAccessDenied &&
|
||
sre.Message.Contains("Too many failed authentication attempts", StringComparison.Ordinal))
|
||
{
|
||
Assert.Fail("Account locked out from prior negative tests; cannot authenticate");
|
||
return;
|
||
}
|
||
try
|
||
{
|
||
Assert.That(session.Connected, Is.True);
|
||
Assert.That(
|
||
session.Endpoint.SecurityMode,
|
||
Is.EqualTo(MessageSecurityMode.None));
|
||
Assert.That(
|
||
session.Endpoint.SecurityPolicyUri,
|
||
Is.EqualTo(SecurityPolicies.None));
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
[Test]
|
||
public async Task NoneSession004InsecureNoCertsNoNonce()
|
||
{
|
||
// Connect on an insecure channel with neither client
|
||
// certificate nor nonce requirement. Verify that the
|
||
// session is fully functional.
|
||
ISession session;
|
||
try
|
||
{
|
||
session = await ClientFixture
|
||
.ConnectAsync(ServerUrl, SecurityPolicies.None)
|
||
.ConfigureAwait(false);
|
||
}
|
||
catch (ServiceResultException sre) when (sre.StatusCode == StatusCodes.BadUserAccessDenied &&
|
||
sre.Message.Contains("Too many failed authentication attempts", StringComparison.Ordinal))
|
||
{
|
||
Assert.Fail("Account locked out from prior negative tests; cannot authenticate");
|
||
return;
|
||
}
|
||
try
|
||
{
|
||
Assert.That(session.Connected, Is.True);
|
||
Assert.That(
|
||
session.Endpoint.SecurityMode,
|
||
Is.EqualTo(MessageSecurityMode.None));
|
||
|
||
// Verify the session is functional by reading
|
||
// the server status current time node.
|
||
DataValue timeValue = await session.ReadValueAsync(
|
||
VariableIds.Server_ServerStatus_CurrentTime,
|
||
CancellationToken.None).ConfigureAwait(false);
|
||
Assert.That(
|
||
StatusCode.IsGood(timeValue.StatusCode), Is.True,
|
||
"Should be able to read server time on " +
|
||
"None session.");
|
||
}
|
||
finally
|
||
{
|
||
await session.CloseAsync(5000, true)
|
||
.ConfigureAwait(false);
|
||
session.Dispose();
|
||
}
|
||
}
|
||
|
||
private async Task<ArrayOf<EndpointDescription>> GetEndpointsAsync()
|
||
{
|
||
var endpointConfiguration =
|
||
EndpointConfiguration.Create(ClientFixture.Config);
|
||
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
|
||
ServerUrl,
|
||
endpointConfiguration,
|
||
Telemetry,
|
||
ct: CancellationToken.None).ConfigureAwait(false);
|
||
|
||
return await client.GetEndpointsAsync(
|
||
default, CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
}
|
||
}
|