/* ======================================================================== * 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/ * ======================================================================*/ // CA2007: tests run without a SynchronizationContext; ConfigureAwait(false) // adds noise without a behavioural benefit. Disabled file-level for the suite. #pragma warning disable CA2007 using System; using System.Globalization; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Time.Testing; using Moq; using NUnit.Framework; using Opc.Ua.Redundancy; using Opc.Ua.Redundancy.Server; using Opc.Ua.Security.Certificates; using Opc.Ua.Tests; namespace Opc.Ua.Server.Tests.Redundancy { /// /// Unit tests for the security decision logic of /// : the SecurityPolicy/Mode /// check and the single-use server-nonce consumption (replay defence) that /// gate a mirrored fast-reconnect. The full reconstruct + signature path is /// exercised by the two-server end-to-end test. /// [TestFixture] [Category("Distributed")] [Parallelizable(ParallelScope.All)] public class DistributedSessionManagerTests { private const string PolicyA = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256"; private const string PolicyB = "http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss"; private static readonly ByteString s_clientChannelCertificate = ByteString.From(CreateBytes(32, 0x41)); private static readonly InMemorySharedKeyValueStore s_sessionKv = new(); private static readonly SharedKeyValueSessionStore s_sessionStore = new(s_sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); private static DistributedSessionManager CreateManager( ISingleUseNonceRegistry nonceRegistry, ISharedSessionStore? sessionStore = null, IServerInternal? server = null, Func? serverCertificateProvider = null, TimeProvider? timeProvider = null) { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); var serverMock = new Mock(); serverMock.Setup(s => s.Telemetry).Returns(telemetry); serverMock.Setup(s => s.NamespaceUris).Returns(new NamespaceTable()); var configuration = new ApplicationConfiguration { ServerConfiguration = new ServerConfiguration { MinSessionTimeout = 1000, MaxSessionTimeout = 3_600_000, MaxSessionCount = 100, MaxRequestAge = 60_000, MaxBrowseContinuationPoints = 10, MaxHistoryContinuationPoints = 10 } }; return new DistributedSessionManager( server ?? serverMock.Object, configuration, sessionStore ?? s_sessionStore, nonceRegistry, serverCertificateProvider ?? (_ => null), new DistributedSessionOptions { EnableFastReconnect = true }, timeProvider); } private static SharedSessionEntry EntryWithNonce(byte[] nonce) { return new SharedSessionEntry { SessionId = new NodeId(1, 1), AuthenticationToken = new NodeId(2, 1), SecurityPolicyUri = PolicyA, SecurityMode = (int)MessageSecurityMode.SignAndEncrypt, ServerNonce = ByteString.From(nonce), SecurityStateVersion = SharedSessionEntry.CurrentSecurityStateVersion, OriginalClientChannelCertificate = s_clientChannelCertificate, ClientUserId = null, ClientUserTokenType = UserTokenType.Anonymous, HasActivatedUserIdentity = true }; } [Test] public async Task AuthorizeSucceedsForMatchingPolicyAndFreshNonceAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 1)); DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.Authorized)); } [Test] public async Task AuthorizeRejectsMismatchedPolicyAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 2)); DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyB, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.PolicyMismatch)); } [Test] public async Task AuthorizeRejectsMismatchedSecurityModeAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 3)); DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.Sign, s_clientChannelCertificate).ConfigureAwait(false); Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.PolicyMismatch)); } [Test] public async Task AuthorizeRejectsReplayedNonceAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 9)); DistributedSessionManager.RestoreDecision first = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); DistributedSessionManager.RestoreDecision second = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That(first, Is.EqualTo(DistributedSessionManager.RestoreDecision.Authorized)); Assert.That(second, Is.EqualTo(DistributedSessionManager.RestoreDecision.NonceReplayed), "a captured activation cannot be replayed once the nonce is consumed"); } [Test] public async Task AuthorizeRejectsEmptyNonceAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce([]); DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.NonceInvalid)); } [Test] public async Task AuthorizeRejectsMissingVersionedSecurityStateAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 10)) with { SecurityStateVersion = 0 }; DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That( decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.SecurityStateMissing)); } [Test] public async Task MirrorKeepsTheNewestNonceWhenActivationsCompleteOutOfOrderAsync() { using var sessionKv = new InMemorySharedKeyValueStore(); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry, sessionStore); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 21)); await sessionStore.PutAsync(entry).ConfigureAwait(false); ByteString newerNonce = ByteString.From(CreateBytes(32, 22)); ByteString olderNonce = ByteString.From(CreateBytes(32, 23)); // The activation gate is released before the mirror callback runs, so // a slower earlier activation can arrive last. Its nonce is stale and // must not replace the nonce of the newer activation. await manager.MirrorActivationIfCurrentAsync( entry.AuthenticationToken, newerNonce, UserTokenType.Anonymous, null, activationSequence: 2, userTokenRequiresEphemeralKey: false, CancellationToken.None).ConfigureAwait(false); await manager.MirrorActivationIfCurrentAsync( entry.AuthenticationToken, olderNonce, UserTokenType.Anonymous, null, activationSequence: 1, userTokenRequiresEphemeralKey: false, CancellationToken.None).ConfigureAwait(false); SharedSessionEntry? stored = await sessionStore .TryGetAsync(entry.AuthenticationToken) .ConfigureAwait(false); Assert.That(stored, Is.Not.Null); Assert.That(stored!.ServerNonce, Is.EqualTo(newerNonce)); } [Test] public async Task MirrorPersistsTheNonceOfAnAdvancingActivationAsync() { using var sessionKv = new InMemorySharedKeyValueStore(); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry, sessionStore); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 24)); await sessionStore.PutAsync(entry).ConfigureAwait(false); ByteString firstNonce = ByteString.From(CreateBytes(32, 25)); ByteString secondNonce = ByteString.From(CreateBytes(32, 26)); await manager.MirrorActivationIfCurrentAsync( entry.AuthenticationToken, firstNonce, UserTokenType.Anonymous, null, activationSequence: 1, userTokenRequiresEphemeralKey: false, CancellationToken.None).ConfigureAwait(false); await manager.MirrorActivationIfCurrentAsync( entry.AuthenticationToken, secondNonce, UserTokenType.Anonymous, null, activationSequence: 2, userTokenRequiresEphemeralKey: false, CancellationToken.None).ConfigureAwait(false); SharedSessionEntry? stored = await sessionStore .TryGetAsync(entry.AuthenticationToken) .ConfigureAwait(false); Assert.That(stored, Is.Not.Null); Assert.That(stored!.ServerNonce, Is.EqualTo(secondNonce)); Assert.That(stored.HasActivatedUserIdentity, Is.True); } [Test] public async Task AuthorizeRejectsPreviousSecurityStateVersionAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); // Version 2 stored a differently derived ClientUserId, so an entry a // peer replica wrote before the upgrade must fail closed instead of // being compared against a key this version computes. SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 11)) with { SecurityStateVersion = 2 }; DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That( decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.SecurityStateMissing)); } [Test] public async Task AuthorizeRejectsMissingClientUserIdAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 11)) with { ClientUserId = null, ClientUserTokenType = UserTokenType.UserName }; DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That( decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.SecurityStateMissing)); } [Test] public async Task AuthorizeRejectsUnactivatedIdentityStateAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 13)) with { HasActivatedUserIdentity = false }; DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That( decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.SecurityStateMissing)); } [Test] public async Task AuthorizeRejectsDifferentOriginalChannelCertificateAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 12)); DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, ByteString.From(CreateBytes(32, 0x61))).ConfigureAwait(false); Assert.That( decision, Is.EqualTo( DistributedSessionManager.RestoreDecision.ClientCertificateMismatch)); } [Test] public async Task AuthorizeRejectsAllZeroNonceAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(new byte[32]); DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.NonceInvalid)); } [Test] public async Task AuthorizeRejectsExpiredSessionAsync() { var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-07-08T12:00:00Z", CultureInfo.InvariantCulture)); using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry, timeProvider: timeProvider); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 4)) with { LastActivatedAt = DateTimeUtc.From(timeProvider.GetUtcNow().UtcDateTime.AddMilliseconds(-1001)), SessionTimeout = 1000 }; DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.Expired)); } [Test] public async Task RestoreRejectsExpiredMirroredSessionAsync() { var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-07-08T12:00:00Z", CultureInfo.InvariantCulture)); using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using DistributedSessionManager manager = CreateManager( registry, sessionStore: sessionStore, timeProvider: timeProvider); var authenticationToken = new NodeId("expired-token", 2); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 5)) with { AuthenticationToken = authenticationToken, LastActivatedAt = DateTimeUtc.From(timeProvider.GetUtcNow().UtcDateTime.AddMilliseconds(-1001)), SessionTimeout = 1000 }; await sessionStore.PutAsync(entry).ConfigureAwait(false); ISession? session = await InvokeRestoreSessionAsync( manager, authenticationToken, CreateContext()).ConfigureAwait(false); Assert.That(session, Is.Null); // Issue #4627 RS-6: an entry nobody can restore any more is removed. Assert.That( await sessionStore.TryGetAsync(authenticationToken).ConfigureAwait(false), Is.Null); } [Test] public void SecureRestoreWithinTimeoutReconstructsClientCertificate() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); using Certificate clientCertificate = CreateCertificate("CN=DistributedClient"); Mock server = CreateRestoreServerMock(true); using DistributedSessionManager manager = CreateManager( registry, server: server.Object, serverCertificateProvider: _ => serverCertificate.AddRef()); SharedSessionEntry entry = CreateSecureEntry(clientCertificate) with { LastActivatedAt = DateTimeUtc.Now, SessionTimeout = 60_000 }; ISession? session = InvokeReconstructSession( manager, entry, entry.AuthenticationToken, CreateContext(clientCertificate.RawData)); Assert.That(session, Is.Not.Null); Assert.That(session!.ClientCertificate, Is.Not.Null); Assert.That(session.ClientCertificate!.Thumbprint, Is.EqualTo(clientCertificate.Thumbprint)); Assert.That(session.EndpointDescription.SecurityMode, Is.EqualTo(MessageSecurityMode.Sign)); Assert.That(session.EndpointDescription.SecurityPolicyUri, Is.EqualTo(PolicyA)); session.Dispose(); } /// /// PR review 4168294597: a restored Session carries the certificate provenance /// of the original, so a certificate whose validation error was accepted on the /// active replica is not a trusted application identity after failover. /// [TestCase(true)] [TestCase(false)] public void RestoredSessionKeepsTheClientCertificateProvenance(bool validated) { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); using Certificate clientCertificate = CreateCertificate("CN=DistributedClient"); Mock server = CreateRestoreServerMock(true); using DistributedSessionManager manager = CreateManager( registry, server: server.Object, serverCertificateProvider: _ => serverCertificate.AddRef()); SharedSessionEntry entry = CreateSecureEntry(clientCertificate) with { ClientCertificateValidated = validated }; ISession? session = InvokeReconstructSession( manager, entry, entry.AuthenticationToken, CreateContext(clientCertificate.RawData)); Assert.That(session, Is.Not.Null); Assert.That(IsClientCertificateValidated(session!), Is.EqualTo(validated)); session!.Dispose(); } /// /// PR review 4168294597: an entry written before the provenance was mirrored is /// still restorable; its certificate is treated as not validated. /// [Test] public async Task AuthorizeAcceptsVersionThreeSecurityStateAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 14)) with { SecurityStateVersion = 3 }; DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.Authorized)); } /// /// PR review 4168294597: the provenance is recorded before the base session /// manager publishes the Session, so the mirror written by the /// CreateSessionAsync override already sees a certificate whose validation /// error was accepted as not validated. /// [TestCase(false)] [TestCase(true)] public async Task CreateSessionMirrorsTheClientCertificateProvenanceAsync(bool errorAccepted) { using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); using Certificate clientCertificate = CreateCertificate("CN=DistributedClient"); Mock server = CreateRestoreServerMock(true); using DistributedSessionManager manager = CreateManager( registry, sessionStore: sessionStore, server: server.Object); using OperationContext context = CreateContext(clientCertificate.RawData); typeof(OperationContext) .GetProperty("ClientCertificateErrorAccepted", BindingFlags.Instance | BindingFlags.NonPublic)! .SetValue(context, errorAccepted); Certificate sessionClientCertificate = clientCertificate.AddRef(); CreateSessionResult result; try { result = await manager.CreateSessionAsync( context, serverCertificate, "provenance", ByteString.From(CreateBytes(32, 15)), new ApplicationDescription { ApplicationUri = "urn:test:client" }, "opc.tcp://localhost:4840", sessionClientCertificate, [], 60_000, 0).ConfigureAwait(false); } catch { sessionClientCertificate.Dispose(); throw; } Assert.That(IsClientCertificateValidated(result.Session), Is.EqualTo(!errorAccepted)); SharedSessionEntry? mirrored = await sessionStore .TryGetAsync(result.AuthenticationToken) .ConfigureAwait(false); Assert.That(mirrored, Is.Not.Null); Assert.That(mirrored!.ClientCertificateValidated, Is.EqualTo(!errorAccepted)); } [Test] public void SecureRestoreWithoutClientCertificateChainIsRejected() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); Mock server = CreateRestoreServerMock(true); using DistributedSessionManager manager = CreateManager( registry, server: server.Object, serverCertificateProvider: _ => serverCertificate.AddRef()); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 6)) with { SecurityMode = (int)MessageSecurityMode.Sign, ClientCertificateChain = ByteString.Empty, SessionTimeout = 60_000 }; ISession? session = InvokeReconstructSession( manager, entry, entry.AuthenticationToken, CreateContext(s_clientChannelCertificate.ToArray())); Assert.That(session, Is.Null); } [Test] public async Task FailedSecureRestoreDisposesTemporaryCertificateHandlesAsync() { using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); Certificate clientCertificate = CreateCertificate("CN=DistributedClient"); Mock server = CreateRestoreServerMock(false); string serverThumbprint = serverCertificate.Thumbprint; string clientThumbprint = clientCertificate.Thumbprint; try { using DistributedSessionManager manager = CreateManager( registry, sessionStore: sessionStore, server: server.Object, serverCertificateProvider: _ => serverCertificate.AddRef()); SharedSessionEntry entry = CreateSecureEntry(clientCertificate) with { SessionTimeout = 60_000 }; await sessionStore.PutAsync(entry).ConfigureAwait(false); OperationContext context = CreateContext(clientCertificate.RawData); InvalidOperationException? exception = null; try { _ = await InvokeRestoreSessionAsync( manager, entry.AuthenticationToken, context).ConfigureAwait(false); } catch (InvalidOperationException ex) { exception = ex; } Assert.That(exception, Is.Not.Null); } finally { serverCertificate.Dispose(); clientCertificate.Dispose(); } Assert.That(GetCertificateRefCount(serverCertificate), Is.Zero, serverThumbprint); Assert.That(GetCertificateRefCount(clientCertificate), Is.Zero, clientThumbprint); } [Test] public void ConstructorValidatesArguments() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); ITelemetryContext telemetry = NUnitTelemetryContext.Create(); IServiceMessageContext context = ServiceMessageContext.CreateEmpty(telemetry); var serverMock = new Mock(); serverMock.Setup(s => s.Telemetry).Returns(telemetry); var configuration = new ApplicationConfiguration { ServerConfiguration = new ServerConfiguration { MinSessionTimeout = 1000, MaxSessionTimeout = 3_600_000, MaxSessionCount = 100 } }; using var kv = new InMemorySharedKeyValueStore(); var store = new SharedKeyValueSessionStore(kv, context); Assert.That( () => new DistributedSessionManager( serverMock.Object, configuration, store, registry, null!), Throws.ArgumentNullException); } /// /// Issue #4627 RS-1: a session in normal use is kept alive by its liveness /// heartbeat, not only by activations, so it stays restorable long after its /// last activation. /// [Test] public void AuthorizeAcceptsSessionKeptAliveByTheHeartbeat() { var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-08T12:00:00Z", CultureInfo.InvariantCulture)); using var registryKv = new InMemorySharedKeyValueStore(); using DistributedSessionManager manager = CreateManager( new SharedSingleUseNonceRegistry(registryKv), timeProvider: timeProvider); DateTime now = timeProvider.GetUtcNow().UtcDateTime; SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 30)) with { LastActivatedAt = DateTimeUtc.From(now.AddMinutes(-10)), LastContactAt = DateTimeUtc.From(now.AddSeconds(-5)), SessionTimeout = 60_000 }; Assert.That( manager.AuthorizeRestore(entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate), Is.EqualTo(DistributedSessionManager.RestoreDecision.Authorized)); } [Test] public void AuthorizeRejectsSessionWhoseHeartbeatStopped() { var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-08T12:00:00Z", CultureInfo.InvariantCulture)); using var registryKv = new InMemorySharedKeyValueStore(); using DistributedSessionManager manager = CreateManager( new SharedSingleUseNonceRegistry(registryKv), timeProvider: timeProvider); DateTime now = timeProvider.GetUtcNow().UtcDateTime; // Timeout plus the heartbeat's maximum lag (a quarter of the timeout plus the // one second liveness interval) has passed since the last mirrored contact. SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 31)) with { LastActivatedAt = DateTimeUtc.From(now.AddMinutes(-10)), LastContactAt = DateTimeUtc.From(now.AddMilliseconds(-(60_000 + 15_000 + 1_000))), SessionTimeout = 60_000 }; Assert.That( manager.AuthorizeRestore(entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate), Is.EqualTo(DistributedSessionManager.RestoreDecision.Expired)); } /// /// Issue #4627 RS-4: a session whose user token is encrypted with an EphemeralKey /// is not restored, and nothing is consumed or counted as a failed authentication. /// [Test] public async Task AuthorizeRejectsEphemeralKeyUserTokenWithoutConsumingTheNonceAsync() { using var registryKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); using DistributedSessionManager manager = CreateManager(registry); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 32)) with { UserTokenRequiresEphemeralKey = true }; DistributedSessionManager.RestoreDecision decision = await manager.AuthorizeAndConsumeAsync( entry, PolicyA, MessageSecurityMode.SignAndEncrypt, s_clientChannelCertificate).ConfigureAwait(false); Assert.That(decision, Is.EqualTo(DistributedSessionManager.RestoreDecision.EphemeralKeyUnavailable)); Assert.That(await registry.TryConsumeAsync(entry.ServerNonce).ConfigureAwait(false), Is.True); } [TestCase(UserTokenType.UserName, null, ExpectedResult = true)] [TestCase(UserTokenType.IssuedToken, null, ExpectedResult = true)] [TestCase(UserTokenType.UserName, PolicyA, ExpectedResult = false)] [TestCase(UserTokenType.Anonymous, null, ExpectedResult = false)] [TestCase(UserTokenType.Certificate, null, ExpectedResult = false)] public bool UserTokenRequiresEphemeralKeyOnlyForEncryptedTokensOfEphemeralKeyPolicies( UserTokenType tokenType, string? tokenSecurityPolicyUri) { if (SecurityPolicies.Default.GetInfo(SecurityPolicies.ECC_nistP256)?.EphemeralKeyAlgorithm is null or CertificateKeyAlgorithm.None) { Assert.Ignore("ECC security policies are not supported on this platform."); } var endpoint = new EndpointDescription { SecurityPolicyUri = SecurityPolicies.ECC_nistP256, SecurityMode = MessageSecurityMode.SignAndEncrypt, UserIdentityTokens = new ArrayOf(new[] { new UserTokenPolicy { PolicyId = "policy", TokenType = tokenType, SecurityPolicyUri = tokenSecurityPolicyUri } }) }; var handler = new Mock(); handler.Setup(h => h.TokenType).Returns(tokenType); handler.Setup(h => h.Token).Returns(new UserNameIdentityToken { PolicyId = "policy" }); var session = new Mock(); session.Setup(s => s.EndpointDescription).Returns(endpoint); session.Setup(s => s.IdentityToken).Returns(handler.Object); return DistributedSessionManager.UserTokenRequiresEphemeralKey(session.Object); } /// /// Issue #4627 RS-5: a restore materializes the session without consuming the /// single-use nonce; it is consumed only when the activation passed validation. /// [Test] public async Task RestoreDoesNotConsumeTheNonceBeforeTheActivationIsValidatedAsync() { using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); using Certificate clientCertificate = CreateCertificate("CN=DistributedClient"); Mock server = CreateRestoreServerMock(true); using DistributedSessionManager manager = CreateManager( registry, sessionStore: sessionStore, server: server.Object, serverCertificateProvider: _ => serverCertificate.AddRef()); SharedSessionEntry entry = CreateSecureEntry(clientCertificate); await sessionStore.PutAsync(entry).ConfigureAwait(false); ISession? first = await InvokeRestoreSessionAsync( manager, entry.AuthenticationToken, CreateContext(clientCertificate.RawData)).ConfigureAwait(false); ISession? second = await InvokeRestoreSessionAsync( manager, entry.AuthenticationToken, CreateContext(clientCertificate.RawData)).ConfigureAwait(false); try { Assert.That(first, Is.Not.Null); Assert.That(second, Is.Not.Null, "a failed or abandoned attempt leaves the session restorable"); Assert.That(await registry.TryConsumeAsync(entry.ServerNonce).ConfigureAwait(false), Is.True); Assert.That(await sessionStore.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false), Is.Not.Null); } finally { first?.Dispose(); second?.Dispose(); } } /// /// Issue #4627 RS-3: the restored session asks for the SessionId the client got /// from CreateSession instead of minting a new one. /// [Test] public async Task RestoreRequestsTheMirroredSessionIdAsync() { using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); using Certificate clientCertificate = CreateCertificate("CN=DistributedClient"); NodeId requested = NodeId.Null; Mock server = CreateRestoreServerMock(true, d => requested = d.SessionId); using DistributedSessionManager manager = CreateManager( new SharedSingleUseNonceRegistry(registryKv), sessionStore: sessionStore, server: server.Object, serverCertificateProvider: _ => serverCertificate.AddRef()); SharedSessionEntry entry = CreateSecureEntry(clientCertificate) with { SessionId = new NodeId(Guid.NewGuid(), 1) }; await sessionStore.PutAsync(entry).ConfigureAwait(false); ISession? session = await InvokeRestoreSessionAsync( manager, entry.AuthenticationToken, CreateContext(clientCertificate.RawData)).ConfigureAwait(false); session?.Dispose(); Assert.That(session, Is.Not.Null); Assert.That(requested, Is.EqualTo(entry.SessionId)); } /// /// Issue #4627 RS-3: a new session asks for a SessionId that is unique across the /// replica set (a per-process counter would repeat on every replica). /// [Test] public async Task CreateSessionRequestsAClusterUniqueSessionIdAsync() { using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); NodeId requested = NodeId.Null; Mock server = CreateRestoreServerMock(true, d => requested = d.SessionId); var namespaces = new NamespaceTable(); int diagnosticsIndex = namespaces.GetIndexOrAppend(Ua.Namespaces.OpcUa + "Diagnostics"); server.Setup(s => s.NamespaceUris).Returns(namespaces); using DistributedSessionManager manager = CreateManager( new SharedSingleUseNonceRegistry(registryKv), sessionStore: sessionStore, server: server.Object); await CreateMirroredSessionAsync(manager, serverCertificate, "unique").ConfigureAwait(false); Assert.That(requested.IdType, Is.EqualTo(IdType.Guid)); Assert.That(requested.NamespaceIndex, Is.EqualTo((ushort)diagnosticsIndex)); } /// /// Issue #4627 RS-2: the created entry names this replica as its owner and carries /// the server certificate the client received. /// [Test] public async Task CreateSessionMirrorsOwnerAndServerCertificateAsync() { using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); using DistributedSessionManager manager = CreateManager( new SharedSingleUseNonceRegistry(registryKv), sessionStore: sessionStore, server: CreateRestoreServerMock(true).Object); CreateSessionResult result = await CreateMirroredSessionAsync(manager, serverCertificate, "owner") .ConfigureAwait(false); SharedSessionEntry? entry = await sessionStore.TryGetAsync(result.AuthenticationToken).ConfigureAwait(false); Assert.That(entry, Is.Not.Null); Assert.That(entry!.OwnerId, Is.EqualTo(manager.ReplicaId)); Assert.That(entry.ServerCertificate, Is.EqualTo(serverCertificate.RawData.ToByteString())); Assert.That(entry.LastContactAt.IsNull, Is.False); } /// /// Issue #4627 RS-8: when the server sends its complete chain in CreateSession the /// entry carries that blob, which the client may have signed. /// [Test] public async Task CreateSessionMirrorsTheAdvertisedServerCertificateChainAsync() { using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); using Certificate issuer = CreateCertificate("CN=DistributedIssuer"); using var chain = new CertificateCollection { serverCertificate, issuer }; ByteString chainBlob = ByteString.From(Utils.CreateCertificateChainBlob(chain)); using DistributedSessionManager manager = CreateManager( new SharedSingleUseNonceRegistry(registryKv), sessionStore: sessionStore, server: CreateRestoreServerMock(true).Object); CreateSessionResult result = await CreateMirroredSessionAsync( manager, serverCertificate, "chain", chainBlob).ConfigureAwait(false); SharedSessionEntry? entry = await sessionStore.TryGetAsync(result.AuthenticationToken).ConfigureAwait(false); Assert.That(entry!.ServerCertificate, Is.EqualTo(chainBlob)); } /// /// Issue #4627 RS-2: a replica whose copy of a session was taken over by another /// replica does not delete the shared entry when that stale copy times out or /// closes; the owner does. /// [TestCase(false, ExpectedResult = true)] [TestCase(true, ExpectedResult = false)] public async Task CloseDeletesTheEntryOnlyOnTheOwningReplicaAsync(bool owned) { using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using Certificate serverCertificate = CreateCertificate("CN=DistributedServer"); using DistributedSessionManager manager = CreateManager( new SharedSingleUseNonceRegistry(registryKv), sessionStore: sessionStore, server: CreateRestoreServerMock(true).Object); CreateSessionResult result = await CreateMirroredSessionAsync(manager, serverCertificate, "close") .ConfigureAwait(false); if (!owned) { SharedSessionEntry entry = (await sessionStore.TryGetAsync(result.AuthenticationToken).ConfigureAwait(false))!; await sessionStore.PutAsync(entry with { OwnerId = "another-replica" }).ConfigureAwait(false); } await manager.CloseSessionAsync(result.SessionId).ConfigureAwait(false); return await sessionStore.TryGetAsync(result.AuthenticationToken).ConfigureAwait(false) != null; } /// /// Issue #4627 RS-2: an activation that committed on this replica records it as the /// owner, together with the liveness of the activation. /// [Test] public async Task MirroredActivationRecordsTheOwnerAsync() { using var sessionKv = new InMemorySharedKeyValueStore(); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using var registryKv = new InMemorySharedKeyValueStore(); using DistributedSessionManager manager = CreateManager( new SharedSingleUseNonceRegistry(registryKv), sessionStore); SharedSessionEntry entry = EntryWithNonce(CreateBytes(32, 33)) with { OwnerId = "another-replica" }; await sessionStore.PutAsync(entry).ConfigureAwait(false); await manager.MirrorActivationIfCurrentAsync( entry.AuthenticationToken, ByteString.From(CreateBytes(32, 34)), UserTokenType.Anonymous, null, activationSequence: 1, userTokenRequiresEphemeralKey: true, CancellationToken.None).ConfigureAwait(false); SharedSessionEntry? stored = await sessionStore.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false); Assert.That(stored!.OwnerId, Is.EqualTo(manager.ReplicaId)); Assert.That(stored.LastContactAt.IsNull, Is.False); Assert.That(stored.UserTokenRequiresEphemeralKey, Is.True); } /// /// Issue #4627 RS-6: the sweep removes the entries of sessions that can no longer /// be restored and the consumed-nonce markers no live entry references. /// [Test] public async Task SweepRemovesExpiredEntriesAndOrphanedNonceMarkersAsync() { var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-08T12:00:00Z", CultureInfo.InvariantCulture)); using var registryKv = new InMemorySharedKeyValueStore(); using var sessionKv = new InMemorySharedKeyValueStore(); var registry = new SharedSingleUseNonceRegistry(registryKv, timeProvider: timeProvider); var sessionStore = new SharedKeyValueSessionStore( sessionKv, ServiceMessageContext.CreateEmpty(NUnitTelemetryContext.Create())); using DistributedSessionManager manager = CreateManager( registry, sessionStore: sessionStore, timeProvider: timeProvider); SharedSessionEntry expired = EntryWithNonce(CreateBytes(32, 40)) with { AuthenticationToken = new NodeId("expired", 2), LastActivatedAt = DateTimeUtc.From(timeProvider.GetUtcNow().UtcDateTime), SessionTimeout = 60_000 }; Assert.That(await registry.TryConsumeAsync(expired.ServerNonce).ConfigureAwait(false), Is.True); ByteString liveNonce = ByteString.From(CreateBytes(32, 41)); Assert.That(await registry.TryConsumeAsync(liveNonce).ConfigureAwait(false), Is.True); timeProvider.Advance(TimeSpan.FromHours(2)); SharedSessionEntry live = EntryWithNonce(CreateBytes(32, 41)) with { AuthenticationToken = new NodeId("live", 2), LastActivatedAt = DateTimeUtc.From(timeProvider.GetUtcNow().UtcDateTime), SessionTimeout = 60_000 }; await sessionStore.PutAsync(expired).ConfigureAwait(false); await sessionStore.PutAsync(live).ConfigureAwait(false); (int entries, int markers) = await manager.SweepAsync(CancellationToken.None).ConfigureAwait(false); Assert.That(entries, Is.EqualTo(1)); Assert.That(markers, Is.EqualTo(1)); Assert.That(await sessionStore.TryGetAsync(expired.AuthenticationToken).ConfigureAwait(false), Is.Null); Assert.That(await sessionStore.TryGetAsync(live.AuthenticationToken).ConfigureAwait(false), Is.Not.Null); Assert.That( await registry.TryConsumeAsync(liveNonce).ConfigureAwait(false), Is.False, "the marker of a nonce a live entry carries is kept"); } private static async Task CreateMirroredSessionAsync( DistributedSessionManager manager, Certificate serverCertificate, string name, ByteString advertisedServerCertificate = default) { using Certificate clientCertificate = CreateCertificate("CN=DistributedClient"); using OperationContext context = CreateContext(clientCertificate.RawData, advertisedServerCertificate); Certificate sessionClientCertificate = clientCertificate.AddRef(); try { return await manager.CreateSessionAsync( context, serverCertificate, name, ByteString.From(CreateBytes(32, (byte)name.Length)), new ApplicationDescription { ApplicationUri = "urn:test:client" }, "opc.tcp://localhost:4840", sessionClientCertificate, [], 60_000, 0).ConfigureAwait(false); } catch { sessionClientCertificate.Dispose(); throw; } } private static SharedSessionEntry CreateSecureEntry(Certificate clientCertificate) { using var clientChain = new CertificateCollection { clientCertificate }; return new SharedSessionEntry { SessionId = new NodeId(1, 1), AuthenticationToken = new NodeId("secure-token", 2), SessionName = "secure-session", CreatedAt = DateTimeUtc.Now, LastActivatedAt = DateTimeUtc.Now, ServerNonce = ByteString.From(CreateBytes(32, 7)), ClientNonce = ByteString.From(new byte[] { 5, 6, 7, 8 }), ClientCertificateChain = ByteString.From(Utils.CreateCertificateChainBlob(clientChain)), SecurityStateVersion = SharedSessionEntry.CurrentSecurityStateVersion, OriginalClientChannelCertificate = clientCertificate.RawData.ToByteString(), ClientUserId = null, ClientUserTokenType = UserTokenType.Anonymous, HasActivatedUserIdentity = true, SecurityPolicyUri = PolicyA, SecurityMode = (int)MessageSecurityMode.Sign, EndpointUrl = "opc.tcp://localhost:4840", SessionTimeout = 60_000, ClientDescription = new ApplicationDescription { ApplicationUri = "urn:test:client", ApplicationName = new LocalizedText("DistributedSessionManagerTests"), ApplicationType = ApplicationType.Client } }; } private static Certificate CreateCertificate(string subject) { return CertificateBuilder .Create(subject) .SetRSAKeySize(CertificateFactory.DefaultKeySize) .CreateForRSA(); } private static OperationContext CreateContext( byte[]? clientChannelCertificate = null, ByteString serverCertificate = default) { var endpoint = new EndpointDescription { EndpointUrl = "opc.tcp://localhost:4840", SecurityPolicyUri = PolicyA, SecurityMode = MessageSecurityMode.Sign, ServerCertificate = serverCertificate }; var channelContext = new SecureChannelContext( "restore-channel", endpoint, RequestEncoding.Binary, clientChannelCertificate, null, null); return new OperationContext( new RequestHeader(), channelContext, RequestType.ActivateSession, RequestLifetime.None); } private static Mock CreateRestoreServerMock( bool initializeSucceeds, Action? onCreateDiagnostics = null) { ITelemetryContext telemetry = NUnitTelemetryContext.Create(); var serverMock = new Mock(); serverMock.Setup(s => s.Telemetry).Returns(telemetry); serverMock.Setup(s => s.NamespaceUris).Returns(new NamespaceTable()); serverMock.Setup(s => s.SubscriptionStore).Returns((ISubscriptionStore)null!); var diagnosticsNodeManager = new Mock(); if (initializeSucceeds) { diagnosticsNodeManager .Setup(m => m.CreateSessionDiagnosticsAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback( (_, diagnostics, _, _, _, _) => onCreateDiagnostics?.Invoke(diagnostics)) .Returns(new ValueTask(new NodeId(5001, 1))); } else { diagnosticsNodeManager .Setup(m => m.CreateSessionDiagnosticsAsync( It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns( new ValueTask( Task.FromException( new InvalidOperationException("diagnostics unavailable")))); } serverMock.Setup(s => s.DiagnosticsNodeManager).Returns(diagnosticsNodeManager.Object); serverMock.Setup(s => s.DefaultSystemContext).Returns(new ServerSystemContext(serverMock.Object)); return serverMock; } private static ISession? InvokeReconstructSession( DistributedSessionManager manager, SharedSessionEntry entry, NodeId authenticationToken, OperationContext context) { MethodInfo method = typeof(DistributedSessionManager).GetMethod( "ReconstructSession", BindingFlags.Instance | BindingFlags.NonPublic)!; return (ISession?)method.Invoke(manager, [entry, authenticationToken, context]); } private static async Task InvokeRestoreSessionAsync( DistributedSessionManager manager, NodeId authenticationToken, OperationContext context) { MethodInfo method = typeof(DistributedSessionManager).GetMethod( "RestoreSessionAsync", BindingFlags.Instance | BindingFlags.NonPublic)!; var task = (ValueTask)method.Invoke(manager, [authenticationToken, context, default(CancellationToken)])!; return await task.ConfigureAwait(false); } private static bool IsClientCertificateValidated(ISession session) { MethodInfo method = typeof(SessionManager).Assembly .GetType("Opc.Ua.Server.ClientCertificateProvenance", throwOnError: true)! .GetMethod("IsValidated", BindingFlags.Static | BindingFlags.Public)!; return (bool)method.Invoke(null, [session])!; } private static int GetCertificateRefCount(Certificate certificate) { object core = typeof(Certificate) .GetField("m_core", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(certificate)!; return (int)core.GetType().GetProperty("RefCount", BindingFlags.Instance | BindingFlags.Public)! .GetValue(core)!; } private static byte[] CreateBytes(int length, byte seed) { byte[] bytes = new byte[length]; for (int i = 0; i < bytes.Length; i++) { bytes[i] = (byte)(seed + i); } return bytes; } } }