/* ======================================================================== * 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.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Moq; using NUnit.Framework; using Opc.Ua.Security.Certificates; using Opc.Ua.Tests; using ServerSession = Opc.Ua.Server.Session; namespace Opc.Ua.Server.Tests { /// /// Direct-construction unit tests for that exercise the /// branches reachable without a full secure-channel crypto handshake: /// constructor guards, request validation, diagnostic counters, locale /// updates, continuation points and the anonymous / username identity token /// validation and activation paths under . /// [TestFixture] [Category("Session")] [Parallelizable] public class SessionCoverageTests { private static readonly ICertificateFactory s_factory = DefaultCertificateFactory.Instance; private static readonly string[] s_preferredLocales = ["en-US", "de-DE"]; private static readonly string[] s_singlePreferredLocale = ["en-US"]; private ITelemetryContext m_telemetry = null!; private Mock m_serverMock = null!; private Certificate m_serverCertificate = null!; [SetUp] public void SetUp() { m_telemetry = NUnitTelemetryContext.Create(); m_serverMock = new Mock(); m_serverMock.Setup(s => s.Telemetry).Returns(m_telemetry); m_serverMock.Setup(s => s.NamespaceUris).Returns(new NamespaceTable()); var sessionManagerMock = new Mock(); m_serverMock.Setup(s => s.SessionManager).Returns(sessionManagerMock.Object); m_serverCertificate = s_factory.CreateCertificate("CN=SessionCoverageServer").CreateForRSA(); } [TearDown] public void TearDown() { m_serverCertificate?.Dispose(); } private static EndpointDescription CreateEndpoint( string securityPolicyUri = null!, MessageSecurityMode securityMode = MessageSecurityMode.None, UserTokenPolicy[]? tokens = null) { return new EndpointDescription { EndpointUrl = "opc.tcp://localhost:4840/Coverage", SecurityMode = securityMode, SecurityPolicyUri = securityPolicyUri ?? SecurityPolicies.None, UserIdentityTokens = tokens != null ? new ArrayOf(tokens) : default }; } private static OperationContext CreateContext( EndpointDescription endpoint, string channelId = "channel-1", RequestType requestType = RequestType.ActivateSession) { var channelContext = new SecureChannelContext(channelId, endpoint, RequestEncoding.Binary); var requestHeader = new RequestHeader(); return new OperationContext(requestHeader, channelContext, requestType, RequestLifetime.None); } private ServerSession CreateSession( EndpointDescription endpoint, string channelId = "channel-1", Certificate? clientCertificate = null) { OperationContext context = CreateContext(endpoint, channelId); return new ServerSession( context, m_serverMock.Object, m_serverCertificate, new NodeId(Guid.NewGuid()), ByteString.Empty, Nonce.CreateNonce(SecurityPolicies.None), "CoverageSession", new ApplicationDescription { ApplicationUri = "urn:coverage:client" }, endpoint.EndpointUrl!, clientCertificate!, [], 60_000, 10, 10); } [Test] public void ConstructorWithNullContextThrows() { Assert.That( () => new ServerSession( null!, m_serverMock.Object, m_serverCertificate, new NodeId(Guid.NewGuid()), ByteString.Empty, Nonce.CreateNonce(SecurityPolicies.None), "s", new ApplicationDescription(), "opc.tcp://localhost:4840", null!, [], 60_000, 10, 10), Throws.TypeOf()); } [Test] public void ConstructorWithNullServerThrows() { EndpointDescription endpoint = CreateEndpoint(); OperationContext context = CreateContext(endpoint); Assert.That( () => new ServerSession( context, null!, m_serverCertificate, new NodeId(Guid.NewGuid()), ByteString.Empty, Nonce.CreateNonce(SecurityPolicies.None), "s", new ApplicationDescription(), endpoint.EndpointUrl!, null!, [], 60_000, 10, 10), Throws.TypeOf()); } [Test] public void ConstructorInitializesDiagnostics() { using ServerSession session = CreateSession(CreateEndpoint()); Assert.That(session.SecureChannelId, Is.EqualTo("channel-1")); Assert.That(session.Activated, Is.False); Assert.That(session.SessionDiagnostics, Is.Not.Null); Assert.That(session.ReadDiagnostics(d => d.SessionName), Is.EqualTo("CoverageSession")); Assert.That(session.Identity, Is.Not.Null); } [Test] public void SetUserTokenSecurityPolicyThenGetNewEphemeralKeyReturnsNullForNoPolicy() { using ServerSession session = CreateSession(CreateEndpoint()); Assert.That(session.GetNewEphemeralKey(), Is.Null); } [Test] public void GetNewEphemeralKeyReturnsKeyWhenPolicySet() { using ServerSession session = CreateSession(CreateEndpoint()); session.SetUserTokenSecurityPolicy(SecurityPolicies.Basic256Sha256); EphemeralKeyType? key = session.GetNewEphemeralKey(); Assert.That(key, Is.Not.Null); Assert.That(key!.PublicKey.IsEmpty, Is.False); } [Test] public void IsSecureChannelValidReflectsChannelId() { using ServerSession session = CreateSession(CreateEndpoint(), channelId: "abc"); Assert.That(session.IsSecureChannelValid("abc"), Is.True); Assert.That(session.IsSecureChannelValid("other"), Is.False); } [Test] public void UpdateLocaleIdsReturnsTrueOnChangeAndFalseOnRepeat() { using ServerSession session = CreateSession(CreateEndpoint()); Assert.That(session.UpdateLocaleIds(new ArrayOf(s_preferredLocales)), Is.True); Assert.That(session.UpdateLocaleIds(new ArrayOf(s_preferredLocales)), Is.False); Assert.That(session.PreferredLocales, Is.EqualTo(s_preferredLocales)); } [Test] public void UpdateLocaleIdsDropsNullLocaleIds() { using ServerSession session = CreateSession(CreateEndpoint()); Assert.That(session.UpdateLocaleIds(new ArrayOf(new string[] { null!, "de-DE" })), Is.True); Assert.That(session.PreferredLocales, Has.Length.EqualTo(1)); Assert.That(session.PreferredLocales[0], Is.EqualTo("de-DE")); } /// /// SessionDiagnostics.LocaleIds reports the LocaleIds as specified by the client, /// null entries included (Part 5 12.11); only PreferredLocales is filtered. /// [Test] public void UpdateLocaleIdsKeepsClientLocaleIdsInDiagnostics() { using ServerSession session = CreateSession(CreateEndpoint()); string[] first = [null!, "de-DE"]; Assert.That(session.UpdateLocaleIds(new ArrayOf(first)), Is.True); string[] diagnostics = [.. session.SessionDiagnostics.LocaleIds]; Assert.That(diagnostics, Is.EqualTo(first)); // a change of the null entries alone still reaches the diagnostics. string[] second = ["de-DE", null!]; Assert.That(session.UpdateLocaleIds(new ArrayOf(second)), Is.False); diagnostics = [.. session.SessionDiagnostics.LocaleIds]; Assert.That(diagnostics, Is.EqualTo(second)); Assert.That(session.PreferredLocales, Has.Length.EqualTo(1)); Assert.That(session.PreferredLocales[0], Is.EqualTo("de-DE")); } [Test] public void ValidateRequestWithNullHeaderThrows() { using ServerSession session = CreateSession(CreateEndpoint()); var channelContext = new SecureChannelContext("channel-1", CreateEndpoint(), RequestEncoding.Binary); Assert.That( () => session.ValidateRequest(null!, channelContext, RequestType.Read), Throws.TypeOf()); } [Test] public void ValidateRequestWithNullChannelOnCloseThrowsSessionIdInvalid() { using ServerSession session = CreateSession(CreateEndpoint()); ServiceResultException? ex = Assert.Throws( () => session.ValidateRequest(new RequestHeader(), null!, RequestType.CloseSession)); Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid)); } [Test] public void ValidateRequestWithWrongChannelThrowsSecureChannelIdInvalid() { using ServerSession session = CreateSession(CreateEndpoint()); var channelContext = new SecureChannelContext("wrong-channel", CreateEndpoint(), RequestEncoding.Binary); ServiceResultException? ex = Assert.Throws( () => session.ValidateRequest(new RequestHeader(), channelContext, RequestType.Read)); Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelIdInvalid)); } [Test] public void ValidateRequestWhenNotActivatedThrowsSessionNotActivated() { using ServerSession session = CreateSession(CreateEndpoint()); var channelContext = new SecureChannelContext("channel-1", CreateEndpoint(), RequestEncoding.Binary); ServiceResultException? ex = Assert.Throws( () => session.ValidateRequest(new RequestHeader(), channelContext, RequestType.Read)); Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadSessionNotActivated)); } [TestCase(RequestType.Read)] [TestCase(RequestType.HistoryRead)] [TestCase(RequestType.Write)] [TestCase(RequestType.HistoryUpdate)] [TestCase(RequestType.Call)] [TestCase(RequestType.CreateMonitoredItems)] [TestCase(RequestType.ModifyMonitoredItems)] [TestCase(RequestType.SetMonitoringMode)] [TestCase(RequestType.SetTriggering)] [TestCase(RequestType.DeleteMonitoredItems)] [TestCase(RequestType.CreateSubscription)] [TestCase(RequestType.ModifySubscription)] [TestCase(RequestType.SetPublishingMode)] [TestCase(RequestType.Publish)] [TestCase(RequestType.Republish)] [TestCase(RequestType.TransferSubscriptions)] [TestCase(RequestType.DeleteSubscriptions)] [TestCase(RequestType.AddNodes)] [TestCase(RequestType.AddReferences)] [TestCase(RequestType.DeleteNodes)] [TestCase(RequestType.DeleteReferences)] [TestCase(RequestType.Browse)] [TestCase(RequestType.BrowseNext)] [TestCase(RequestType.TranslateBrowsePathsToNodeIds)] [TestCase(RequestType.QueryFirst)] [TestCase(RequestType.QueryNext)] [TestCase(RequestType.RegisterNodes)] [TestCase(RequestType.UnregisterNodes)] public void ValidateRequestUpdatesDiagnosticCounterForRequestType(RequestType requestType) { using ServerSession session = CreateSession(CreateEndpoint()); var channelContext = new SecureChannelContext("channel-1", CreateEndpoint(), RequestEncoding.Binary); // Non-activated session records the counter (error path) then throws. Assert.That( () => session.ValidateRequest(new RequestHeader(), channelContext, requestType), Throws.TypeOf()); Assert.That(session.ReadDiagnostics(d => d.TotalRequestCount.TotalCount), Is.GreaterThan(0)); } [Test] public void ValidateRequestWithUnknownRequestTypeThrowsUnexpected() { using ServerSession session = CreateSession(CreateEndpoint()); var channelContext = new SecureChannelContext("channel-1", CreateEndpoint(), RequestEncoding.Binary); Assert.That( () => session.ValidateRequest(new RequestHeader(), channelContext, (RequestType)9999), Throws.TypeOf()); } [Test] public void ValidateDiagnosticInfoWithoutAdditionalInfoMaskDoesNothing() { using ServerSession session = CreateSession(CreateEndpoint()); var header = new RequestHeader { ReturnDiagnostics = 0 }; Assert.That(() => session.ValidateDiagnosticInfo(header), Throws.Nothing); Assert.That(header.ReturnDiagnostics, Is.Zero); } [Test] public void SaveAndRestoreContinuationPointRoundTrips() { using ServerSession session = CreateSession(CreateEndpoint()); var id = Guid.NewGuid(); var cp = new ContinuationPoint { Id = id }; session.ContinuationPoints.SaveBrowse(cp); ContinuationPoint? restored = session.ContinuationPoints.RestoreBrowse( new ByteString(id.ToByteArray())); Assert.That(restored, Is.Not.Null); Assert.That(restored!.Id, Is.EqualTo(id)); restored.Dispose(); } [Test] public void SaveContinuationPointWithNullThrows() { using ServerSession session = CreateSession(CreateEndpoint()); Assert.That( () => session.ContinuationPoints.SaveBrowse(null!), Throws.TypeOf()); } [Test] public void RestoreContinuationPointReturnsNullForUnknownAndBadLength() { using ServerSession session = CreateSession(CreateEndpoint()); session.ContinuationPoints.SaveBrowse(new ContinuationPoint { Id = Guid.NewGuid() }); Assert.That(session.ContinuationPoints.RestoreBrowse(new ByteString(new byte[] { 1, 2, 3 })), Is.Null); Assert.That(session.ContinuationPoints.RestoreBrowse(new ByteString(Guid.NewGuid().ToByteArray())), Is.Null); } [Test] public void SaveContinuationPointEvictsOldestWhenOverCapacity() { using ServerSession session = CreateSession(CreateEndpoint()); // Capacity is 10 (maxBrowseContinuationPoints); adding 12 evicts the oldest. var first = new ContinuationPoint { Id = Guid.NewGuid() }; session.ContinuationPoints.SaveBrowse(first); for (int i = 0; i < 11; i++) { session.ContinuationPoints.SaveBrowse(new ContinuationPoint { Id = Guid.NewGuid() }); } Assert.That( session.ContinuationPoints.RestoreBrowse(new ByteString(first.Id.ToByteArray())), Is.Null, "The oldest continuation point should have been evicted."); } [Test] public void ClientApplicationUriReadsTheDiagnosticsUnderTheLock() { using ServerSession session = CreateSession(CreateEndpoint()); // The four server-side callers that used to reach through SessionDiagnostics // want exactly this, and it is read under the diagnostics lock rather than off // a handed-out structure. Assert.That( session.ClientApplicationUri, Is.EqualTo(session.ReadDiagnostics(d => d.ClientDescription?.ApplicationUri))); } [Test] public void SessionNameDoesNotDependOnTheDiagnostics() { using ServerSession session = CreateSession(CreateEndpoint()); // Assigned once during construction, so it is read from the field: a caller // must not have to take the diagnostics lock for a value that never changes. Assert.That( session.SessionName, Is.EqualTo(session.ReadDiagnostics(d => d.SessionName))); } [Test] public void SaveAndRestoreHistoryContinuationPointRoundTrips() { using ServerSession session = CreateSession(CreateEndpoint()); var id = Guid.NewGuid(); var payload = new TestHistoryContinuationPoint(id); session.ContinuationPoints.SaveHistory(payload); IHistoryContinuationPoint? restored = session.ContinuationPoints.RestoreHistory(new ByteString(id.ToByteArray())); Assert.That(restored, Is.SameAs(payload)); } [Test] public void SaveHistoryContinuationPointWithNullThrows() { using ServerSession session = CreateSession(CreateEndpoint()); Assert.That( () => session.ContinuationPoints.SaveHistory(null!), Throws.TypeOf()); } [Test] public void RestoreHistoryContinuationPointReturnsNullForBadLength() { using ServerSession session = CreateSession(CreateEndpoint()); session.ContinuationPoints.SaveHistory(new TestHistoryContinuationPoint()); Assert.That(session.ContinuationPoints.RestoreHistory(new ByteString(new byte[] { 1, 2 })), Is.Null); } [Test] public void ConstructorWithNullChannelContextThrows() { var requestHeader = new RequestHeader(); var context = new OperationContext( requestHeader, null, RequestType.ActivateSession, RequestLifetime.None); ServiceResultException? ex = Assert.Throws( () => new ServerSession( context, m_serverMock.Object, m_serverCertificate, new NodeId(Guid.NewGuid()), ByteString.Empty, Nonce.CreateNonce(SecurityPolicies.None), "s", new ApplicationDescription(), "opc.tcp://localhost:4840", null!, [], 60_000, 10, 10)); Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelIdInvalid)); } [Test] public async Task ValidateDiagnosticInfoGrantsUserPermissionInfoForSecurityAdminAsync() { UserTokenPolicy[] tokens = [ new UserTokenPolicy { PolicyId = "anon", TokenType = UserTokenType.Anonymous } ]; EndpointDescription endpoint = CreateEndpoint(tokens: tokens); using ServerSession session = CreateSession(endpoint, channelId: "channel-1"); OperationContext context = CreateContext(endpoint, channelId: "channel-1"); (IUserIdentityTokenHandler handler, UserTokenPolicy? _) = await session.ValidateBeforeActivateAsync( context, new SignatureData(), default, new SignatureData(), CancellationToken.None).ConfigureAwait(false); var effectiveIdentity = new Mock(); effectiveIdentity.Setup(i => i.TokenType).Returns(UserTokenType.Anonymous); effectiveIdentity.Setup(i => i.DisplayName).Returns("admin"); effectiveIdentity .Setup(i => i.GrantedRoleIds) .Returns(new ArrayOf(new[] { ObjectIds.WellKnownRole_SecurityAdmin })); session.Activate( context, handler!, new UserIdentity(), effectiveIdentity.Object, default, Nonce.CreateNonce(SecurityPolicies.None)); const uint mask = (uint)DiagnosticsMasks.ServiceAdditionalInfo; var header = new RequestHeader { ReturnDiagnostics = mask }; session.ValidateDiagnosticInfo(header); Assert.That( header.ReturnDiagnostics & (uint)DiagnosticsMasks.UserPermissionAdditionalInfo, Is.Not.Zero); } [Test] public void ValidateBeforeActivateWithClientCertificateAndEmptySignatureThrows() { using Certificate clientCertificate = s_factory.CreateCertificate("CN=CoverageClient").CreateForRSA(); EndpointDescription endpoint = CreateEndpoint( SecurityPolicies.Basic256Sha256, MessageSecurityMode.Sign); using ServerSession session = CreateSession( endpoint, channelId: "channel-1", clientCertificate: clientCertificate); OperationContext context = CreateContext(endpoint, channelId: "channel-1"); ServiceResultException? ex = Assert.ThrowsAsync( async () => await session.ValidateBeforeActivateAsync( context, new SignatureData(), default, new SignatureData(), CancellationToken.None).ConfigureAwait(false)); Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadApplicationSignatureInvalid)); } [Test] public void SaveHistoryContinuationPointEvictsOldestWhenOverCapacity() { using ServerSession session = CreateSession(CreateEndpoint()); var first = Guid.NewGuid(); session.ContinuationPoints.SaveHistory(new TestHistoryContinuationPoint(first)); for (int i = 0; i < 11; i++) { session.ContinuationPoints.SaveHistory(new TestHistoryContinuationPoint()); } Assert.That( session.ContinuationPoints.RestoreHistory(new ByteString(first.ToByteArray())), Is.Null, "The oldest history continuation point should have been evicted."); } [Test] public void ValidateBeforeActivateWithSecurityPolicyMismatchThrows() { using ServerSession session = CreateSession(CreateEndpoint(SecurityPolicies.None)); OperationContext context = CreateContext( CreateEndpoint(SecurityPolicies.Basic256Sha256, MessageSecurityMode.Sign)); ServiceResultException? ex = Assert.ThrowsAsync( async () => await session.ValidateBeforeActivateAsync( context, new SignatureData(), default, new SignatureData(), CancellationToken.None).ConfigureAwait(false)); Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadSecurityPolicyRejected)); } /// /// PR review 4166517637: a stale prepared replacement nonce that the /// activation detaches is disposed even when the channel check throws. /// [Test] public void ValidateBeforeActivateFailureDisposesStalePreparedNonce() { if (SecurityPolicies.Default.GetInfo(SecurityPolicies.ECC_nistP256) == null) { Assert.Ignore("ECC_nistP256 is not supported on this platform."); } using ServerSession session = CreateSession(CreateEndpoint(SecurityPolicies.None)); OperationContext context = CreateContext( CreateEndpoint(SecurityPolicies.Basic256Sha256, MessageSecurityMode.Sign)); Nonce stale = Nonce.CreateNonce(SecurityPolicies.ECC_nistP256); System.Reflection.FieldInfo ecdhField = typeof(Nonce).GetField( "m_ecdh", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; Assert.That(ecdhField.GetValue(stale), Is.Not.Null); typeof(ServerSession).GetField( "m_preparedUserTokenNonce", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! .SetValue(session, stale); ServiceResultException? ex = Assert.ThrowsAsync( async () => await session.ValidateBeforeActivateAsync( context, new SignatureData(), default, new SignatureData(), CancellationToken.None).ConfigureAwait(false)); Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadSecurityPolicyRejected)); Assert.That(ecdhField.GetValue(stale), Is.Null, "The stale prepared nonce was not disposed."); } [Test] public void ValidateBeforeActivateWithWrongChannelThrows() { using ServerSession session = CreateSession(CreateEndpoint(), channelId: "channel-1"); OperationContext context = CreateContext(CreateEndpoint(), channelId: "other-channel"); ServiceResultException? ex = Assert.ThrowsAsync( async () => await session.ValidateBeforeActivateAsync( context, new SignatureData(), default, new SignatureData(), CancellationToken.None).ConfigureAwait(false)); Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelIdInvalid)); } [Test] public async Task ValidateBeforeActivateWithAnonymousTokenSucceedsAsync() { var tokens = new UserTokenPolicy[] { new() { PolicyId = "anon", TokenType = UserTokenType.Anonymous } }; EndpointDescription endpoint = CreateEndpoint(tokens: tokens); using ServerSession session = CreateSession(endpoint); OperationContext context = CreateContext(endpoint); (IUserIdentityTokenHandler handler, UserTokenPolicy? policy) = await session.ValidateBeforeActivateAsync( context, new SignatureData(), default, new SignatureData(), CancellationToken.None).ConfigureAwait(false); Assert.That(handler, Is.Not.Null); Assert.That(policy, Is.Not.Null); Assert.That(policy!.TokenType, Is.EqualTo(UserTokenType.Anonymous)); } [Test] public void ValidateBeforeActivateAnonymousNotSupportedThrows() { var tokens = new UserTokenPolicy[] { new() { PolicyId = "user", TokenType = UserTokenType.UserName } }; EndpointDescription endpoint = CreateEndpoint(tokens: tokens); using ServerSession session = CreateSession(endpoint); OperationContext context = CreateContext(endpoint); ServiceResultException? ex = Assert.ThrowsAsync( async () => await session.ValidateBeforeActivateAsync( context, new SignatureData(), default, new SignatureData(), CancellationToken.None).ConfigureAwait(false)); Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenRejected)); } [Test] public async Task ValidateBeforeActivateWithUserNameTokenResolvesPolicyAsync() { var tokens = new UserTokenPolicy[] { new() { PolicyId = "user", TokenType = UserTokenType.UserName, SecurityPolicyUri = SecurityPolicies.None } }; EndpointDescription endpoint = CreateEndpoint(tokens: tokens); using ServerSession session = CreateSession(endpoint); OperationContext context = CreateContext(endpoint); var userToken = new UserNameIdentityToken { PolicyId = "user", UserName = "bob", Password = new ByteString(new byte[] { 1, 2, 3 }) }; (IUserIdentityTokenHandler handler, UserTokenPolicy? policy) = await session.ValidateBeforeActivateAsync( context, new SignatureData(), new ExtensionObject(userToken), new SignatureData(), CancellationToken.None).ConfigureAwait(false); Assert.That(handler, Is.Not.Null); Assert.That(policy, Is.Not.Null); Assert.That(policy!.TokenType, Is.EqualTo(UserTokenType.UserName)); } [Test] public async Task ActivateFirstThenReactivateSwitchesChannelAsync() { var tokens = new UserTokenPolicy[] { new() { PolicyId = "anon", TokenType = UserTokenType.Anonymous } }; EndpointDescription endpoint = CreateEndpoint(tokens: tokens); using ServerSession session = CreateSession(endpoint, channelId: "channel-1"); OperationContext context = CreateContext(endpoint, channelId: "channel-1"); (IUserIdentityTokenHandler handler, UserTokenPolicy? _) = await session.ValidateBeforeActivateAsync( context, new SignatureData(), default, new SignatureData(), CancellationToken.None).ConfigureAwait(false); bool changed = session.Activate( context, handler!, new UserIdentity(), new UserIdentity(), new ArrayOf(s_singlePreferredLocale), Nonce.CreateNonce(SecurityPolicies.None)); Assert.That(session.Activated, Is.True); Assert.That(changed, Is.True); // Re-activate on a different channel exercises the RE-ACTIVATION branch. OperationContext reactivateContext = CreateContext(endpoint, channelId: "channel-2"); session.Activate( reactivateContext, handler!, new UserIdentity(), new UserIdentity(), new ArrayOf(s_singlePreferredLocale), Nonce.CreateNonce(SecurityPolicies.None)); Assert.That(session.SecureChannelId, Is.EqualTo("channel-2")); } [Test] public void ProcessCreateSessionAdditionalParametersReturnsNullForNullInput() { AdditionalParametersType? result = SessionSecurityPolicyHelper.ProcessCreateSessionAdditionalParameters( Mock.Of(), null!, NullLogger.Instance); Assert.That(result, Is.Null); } [Test] public void ProcessCreateSessionAdditionalParametersPreservesUnknownParameters() { var input = new AdditionalParametersType { Parameters = [ new KeyValuePair { Key = new QualifiedName("Unknown"), Value = Variant.From("value") } ] }; AdditionalParametersType? result = SessionSecurityPolicyHelper.ProcessCreateSessionAdditionalParameters( Mock.Of(), input, NullLogger.Instance); Assert.That(result, Is.Not.Null); Assert.That(result!.Parameters, Has.Count.EqualTo(1)); Assert.That(result.Parameters[0].Key, Is.EqualTo(new QualifiedName("Unknown"))); Assert.That(result.Parameters[0].Value.TryGetValue(out string value), Is.True); Assert.That(value, Is.EqualTo("value")); } [Test] public void ProcessCreateSessionAdditionalParametersRejectsUnsupportedEcdhPolicy() { var input = new AdditionalParametersType { Parameters = [ new KeyValuePair { Key = QualifiedName.From(AdditionalParameterNames.ECDHPolicyUri), Value = Variant.From(SecurityPolicies.None) } ] }; AdditionalParametersType? result = SessionSecurityPolicyHelper.ProcessCreateSessionAdditionalParameters( Mock.Of(), input, NullLogger.Instance); Assert.That(result, Is.Not.Null); Assert.That(result!.Parameters, Has.Count.EqualTo(1)); Assert.That(result.Parameters[0].Key, Is.EqualTo(QualifiedName.From(AdditionalParameterNames.ECDHKey))); Assert.That(result.Parameters[0].Value.TryGetValue(out StatusCode status), Is.True); Assert.That(status.Code, Is.EqualTo(StatusCodes.BadSecurityPolicyRejected)); } [Test] public void ProcessCreateSessionAdditionalParametersCreatesEcdhKeyForSupportedPolicy() { var key = new EphemeralKeyType(); const string policyUri = SecurityPolicies.ECC_nistP256; var policies = new Mock(); policies.Setup(p => p.GetInfo(policyUri)).Returns(SecurityPolicyInfo.ECC_nistP256); var session = new Mock(); session.Setup(s => s.GetNewEphemeralKey()).Returns(key); var input = new AdditionalParametersType { Parameters = [ new KeyValuePair { Key = QualifiedName.From(AdditionalParameterNames.ECDHPolicyUri), Value = Variant.From(policyUri) } ] }; AdditionalParametersType? result = SessionSecurityPolicyHelper.ProcessCreateSessionAdditionalParameters( session.Object, input, NullLogger.Instance, policies.Object); Assert.That(result, Is.Not.Null); Assert.That(result!.Parameters, Has.Count.EqualTo(1)); Assert.That(result.Parameters[0].Key, Is.EqualTo(QualifiedName.From(AdditionalParameterNames.ECDHKey))); Variant value = result.Parameters[0].Value; Assert.That(value.TypeInfo, Is.EqualTo(TypeInfo.Scalars.ExtensionObject)); Assert.That( value.TryGetStructure(out EphemeralKeyType? actualKey), Is.True); Assert.That(actualKey, Is.SameAs(key)); session.Verify(s => s.SetUserTokenSecurityPolicy(policyUri), Times.Once); session.Verify(s => s.GetNewEphemeralKey(), Times.Once); } [Test] public void ProcessCreateSessionAdditionalParametersHonorsRawEcdhPlatformSupport() { const string policyUri = SecurityPolicies.ECC_nistP256; var key = new EphemeralKeyType(); var session = new Mock(); session.Setup(s => s.GetNewEphemeralKey()).Returns(key); var input = new AdditionalParametersType { Parameters = [ new KeyValuePair { Key = QualifiedName.From(AdditionalParameterNames.ECDHPolicyUri), Value = Variant.From(policyUri) } ] }; AdditionalParametersType? result = SessionSecurityPolicyHelper.ProcessCreateSessionAdditionalParameters( session.Object, input, NullLogger.Instance); Assert.That(result, Is.Not.Null); Assert.That(result!.Parameters, Has.Count.EqualTo(1)); Assert.That(result.Parameters[0].Key, Is.EqualTo(QualifiedName.From(AdditionalParameterNames.ECDHKey))); Variant value = result.Parameters[0].Value; Assert.That(SecurityPolicies.Default.GetInfo(policyUri), Is.Not.Null); Assert.That(value.TypeInfo, Is.EqualTo(TypeInfo.Scalars.ExtensionObject)); Assert.That(value.TryGetStructure(out EphemeralKeyType? actualKey), Is.True); Assert.That(actualKey, Is.SameAs(key)); session.Verify(s => s.SetUserTokenSecurityPolicy(policyUri), Times.Once); session.Verify(s => s.GetNewEphemeralKey(), Times.Once); } /// /// The response carries only the parameters the server produces; request /// parameters such as "Existing" are not echoed back (OPC 10000-6 6.8.2). /// [Test] public void ProcessActivateSessionAdditionalParametersReturnsOnlyTheNewKey() { var key = new EphemeralKeyType(); var session = new Mock(); session.Setup(s => s.GetNewEphemeralKey()).Returns(key); var input = new AdditionalParametersType { Parameters = [ new KeyValuePair { Key = new QualifiedName("Existing"), Value = Variant.From(1) } ] }; AdditionalParametersType result = SessionSecurityPolicyHelper.ProcessActivateSessionAdditionalParameters( session.Object, input); Assert.That(result, Is.Not.SameAs(input)); Assert.That(result.Parameters, Has.Count.EqualTo(1)); Assert.That(result.Parameters[0].Key, Is.EqualTo(QualifiedName.From(AdditionalParameterNames.ECDHKey))); Variant value = result.Parameters[0].Value; Assert.That(value.TypeInfo, Is.EqualTo(TypeInfo.Scalars.ExtensionObject)); Assert.That( value.TryGetStructure(out EphemeralKeyType? actualKey), Is.True); Assert.That(actualKey, Is.SameAs(key)); session.Verify(s => s.GetNewEphemeralKey(), Times.Once); } [Test] public void ProcessActivateSessionAdditionalParametersReturnsNoParametersWhenNoKeyAvailable() { var session = new Mock(); session.Setup(s => s.GetNewEphemeralKey()).Returns((EphemeralKeyType?)null); var input = new AdditionalParametersType(); AdditionalParametersType result = SessionSecurityPolicyHelper.ProcessActivateSessionAdditionalParameters( session.Object, input); Assert.That(result.Parameters.IsEmpty, Is.True); } /// /// A client that omits the additional header still receives a new key once /// the session uses an ECC user token policy (OPC 10000-6 6.8.2). /// [Test] public void ProcessActivateSessionAdditionalParametersRotatesKeyWithoutRequestParameters() { var key = new EphemeralKeyType(); var session = new Mock(); session.Setup(s => s.GetNewEphemeralKey()).Returns(key); AdditionalParametersType result = SessionSecurityPolicyHelper.ProcessActivateSessionAdditionalParameters( session.Object, null); Assert.That(result.Parameters, Has.Count.EqualTo(1)); Assert.That(result.Parameters[0].Key, Is.EqualTo(QualifiedName.From(AdditionalParameterNames.ECDHKey))); Assert.That( result.Parameters[0].Value.TryGetStructure(out EphemeralKeyType? actualKey), Is.True); Assert.That(actualKey, Is.SameAs(key)); } /// /// A valid ECDHPolicyUri in ActivateSession selects the policy of the returned /// key (OPC 10000-6 6.8.2). /// [Test] public void ProcessActivateSessionAdditionalParametersHonorsEcdhPolicyUri() { const string policyUri = SecurityPolicies.ECC_nistP256; var key = new EphemeralKeyType(); var policies = new Mock(); policies.Setup(p => p.GetInfo(policyUri)).Returns(SecurityPolicyInfo.ECC_nistP256); var session = new Mock(); session.Setup(s => s.GetNewEphemeralKey()).Returns(key); var input = new AdditionalParametersType { Parameters = [ new KeyValuePair { Key = QualifiedName.From(AdditionalParameterNames.ECDHPolicyUri), Value = Variant.From(policyUri) } ] }; AdditionalParametersType result = SessionSecurityPolicyHelper.ProcessActivateSessionAdditionalParameters( session.Object, input, NullLogger.Instance, policies.Object); Assert.That(result.Parameters, Has.Count.EqualTo(1)); Assert.That(result.Parameters[0].Key, Is.EqualTo(QualifiedName.From(AdditionalParameterNames.ECDHKey))); Assert.That( result.Parameters[0].Value.TryGetStructure(out EphemeralKeyType? actualKey), Is.True); Assert.That(actualKey, Is.SameAs(key)); session.Verify(s => s.SetUserTokenSecurityPolicy(policyUri), Times.Once); session.Verify(s => s.GetNewEphemeralKey(), Times.Once); } /// /// An unsupported ECDHPolicyUri in ActivateSession is answered with /// Bad_SecurityPolicyRejected and leaves the session's policy alone. /// [Test] public void ProcessActivateSessionAdditionalParametersRejectsUnsupportedEcdhPolicy() { var session = new Mock(); var input = new AdditionalParametersType { Parameters = [ new KeyValuePair { Key = QualifiedName.From(AdditionalParameterNames.ECDHPolicyUri), Value = Variant.From(SecurityPolicies.None) } ] }; AdditionalParametersType result = SessionSecurityPolicyHelper.ProcessActivateSessionAdditionalParameters( session.Object, input, NullLogger.Instance); Assert.That(result.Parameters, Has.Count.EqualTo(1)); Assert.That(result.Parameters[0].Key, Is.EqualTo(QualifiedName.From(AdditionalParameterNames.ECDHKey))); Assert.That(result.Parameters[0].Value.TryGetValue(out StatusCode status), Is.True); Assert.That(status.Code, Is.EqualTo(StatusCodes.BadSecurityPolicyRejected)); session.Verify(s => s.SetUserTokenSecurityPolicy(It.IsAny()), Times.Never); session.Verify(s => s.GetNewEphemeralKey(), Times.Never); } } }