Files
UA-.NETStandard/tests/Opc.Ua.Redundancy.Server.Tests/Sessions/DistributedSessionManagerTests.cs
romanettandClaude Opus 5.5 1da9fc0543 Redundancy.Server session mirroring: fix fast reconnect lifecycle (#4627) (#4704)
Fixes #4627 — the eight Redundancy.Server session mirroring defects
(HotAndMirrored fast reconnect).

## Changes

| ID | Fix |
|----|-----|
| RS-1 | The owning replica mirrors a liveness heartbeat
(`LastContactAt`), written at most once per quarter of the session
timeout while the client uses the session
(`DistributedSessionOptions.LivenessInterval`, default 1 s). A restore
expires the timeout after the later of the last activation and the last
mirrored contact, plus the heartbeat's maximum lag. |
| RS-2 | Entries record their owning replica (`OwnerId`). Only the owner
mirrors and deletes the entry. A restore takes ownership with a
conditional write. A stale copy on the previous replica is dropped
without deleting the entry, both when its heartbeat sees the change and
when the client fails back to it. `ISharedSessionStore` gains
`TryReplaceAsync`, `TryRemoveAsync` and `EnumerateAsync`. Over a store
without CAS (the CRDT gossip store) they fall back to
compare-then-write. |
| RS-3 | A restored session keeps the SessionId from `CreateSession`:
`Session.RequestedId`, and `DiagnosticsNodeManager` honours a requested
id when it is free. `DistributedSessionManager` mints GUID SessionIds
that are unique across the replica set. Continuation points therefore
stay under one owner id across repeated failovers. |
| RS-4 | Sessions whose user token is encrypted with an ECC/RSA-DH
EphemeralKey are flagged. Their restore is rejected up front
(`EphemeralKeyUnavailable`), before anything is consumed or counted as a
failed authentication. The documented option from the issue was chosen
over mirroring ephemeral private keys. |
| RS-5 | New `SessionManager.AdmitRestoredSessionAsync` hook: the
mirrored nonce is consumed only after the restored activation has passed
the signature, identity and continuity checks. If a restored session's
first activation fails, the session is discarded without the virtual
close, so the shared entry is untouched. Restored copies that are not
admitted now also release their diagnostics node, and
`CurrentSessionCount` is balanced for restored sessions. |
| RS-6 | Expired entries are deleted on restore and by a periodic sweep
(`SweepInterval`, default 5 min). The sweep also purges consumed-nonce
markers that no live entry references and that are older than
`NonceMarkerRetention` (default 1 h), via
`ISingleUseNonceRegistry.PurgeAsync`. |
| RS-7 | `SharedKeyValueSessionStore` and
`DistributedSessionManagerFactory` now throw for an external store
without an `IRecordProtector`, as the DI registration already does. Pass
`NullRecordProtector.Instance` to opt out explicitly. |
| RS-8 | Entries carry the original server certificate. A restored
session accepts a client signature over either that certificate or its
own one (`Session.OriginalServerCertificate`). |

Entries are now written at `SecurityStateVersion` 5. Entries at versions
3 and 4 can still be restored, without an owner.

### API changes (Redundancy.Server)
- New members on `ISharedSessionStore` and `ISingleUseNonceRegistry`.
- An optional `TimeProvider` parameter on
`SharedSingleUseNonceRegistry`.
- New `DistributedSessionOptions` properties.
- `SharedSessionEntry` v5 fields.
- `SharedKeyValueSessionStore` and `DistributedSessionManagerFactory`
throw for external stores without a protector.

## Tests
- New unit tests for every finding: the
restore/heartbeat/ownership/sweep/SessionId paths of
`DistributedSessionManager`; store conditional writes, the CAS fallback
and v5 round-trips; the registry purge; the factory/store guard; base
`SessionRestoreTests` (admitted once, discarded on validation failure or
rejected admission); and `SessionOriginalServerCertificateTests`.
- New end-to-end test
`SessionUsedPastItsTimeoutFailsOverWithItsSessionIdAsync`. The client
uses the session for 2× its timeout, then fails over. The test checks
that the SessionId is preserved and that the standby serves that
SessionId and owns the entry. It also closes the stale copy and checks
the entry survives. With the heartbeat disabled, the test fails as
expected.
- Local net10.0 runs: Redundancy.Server.Tests 883/883, Server.Tests 8347
passed / 5 skipped, Sessions.Tests 849 passed / 379 skipped (the usual
baseline). The net48 build is clean. The Redundancy session tests pass
on net48; the ECC-only cases are skipped there.

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

---------

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

1339 lines
62 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/
* ======================================================================*/
// 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
{
/// <summary>
/// Unit tests for the security decision logic of
/// <see cref="DistributedSessionManager"/>: 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.
/// </summary>
[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<string, Certificate?>? serverCertificateProvider = null,
TimeProvider? timeProvider = null)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var serverMock = new Mock<IServerInternal>();
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<IServerInternal> 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();
}
/// <summary>
/// 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.
/// </summary>
[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<IServerInternal> 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();
}
/// <summary>
/// PR review 4168294597: an entry written before the provenance was mirrored is
/// still restorable; its certificate is treated as not validated.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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<IServerInternal> 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<IServerInternal> 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<IServerInternal> 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<IServerInternal>();
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);
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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<UserTokenPolicy>(new[]
{
new UserTokenPolicy
{
PolicyId = "policy",
TokenType = tokenType,
SecurityPolicyUri = tokenSecurityPolicyUri
}
})
};
var handler = new Mock<IUserIdentityTokenHandler>();
handler.Setup(h => h.TokenType).Returns(tokenType);
handler.Setup(h => h.Token).Returns(new UserNameIdentityToken { PolicyId = "policy" });
var session = new Mock<ISession>();
session.Setup(s => s.EndpointDescription).Returns(endpoint);
session.Setup(s => s.IdentityToken).Returns(handler.Object);
return DistributedSessionManager.UserTokenRequiresEphemeralKey(session.Object);
}
/// <summary>
/// Issue #4627 RS-5: a restore materializes the session without consuming the
/// single-use nonce; it is consumed only when the activation passed validation.
/// </summary>
[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<IServerInternal> 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();
}
}
/// <summary>
/// Issue #4627 RS-3: the restored session asks for the SessionId the client got
/// from CreateSession instead of minting a new one.
/// </summary>
[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<IServerInternal> 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));
}
/// <summary>
/// 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).
/// </summary>
[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<IServerInternal> 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));
}
/// <summary>
/// Issue #4627 RS-2: the created entry names this replica as its owner and carries
/// the server certificate the client received.
/// </summary>
[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);
}
/// <summary>
/// Issue #4627 RS-8: when the server sends its complete chain in CreateSession the
/// entry carries that blob, which the client may have signed.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[TestCase(false, ExpectedResult = true)]
[TestCase(true, ExpectedResult = false)]
public async Task<bool> 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;
}
/// <summary>
/// Issue #4627 RS-2: an activation that committed on this replica records it as the
/// owner, together with the liveness of the activation.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<CreateSessionResult> 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<IServerInternal> CreateRestoreServerMock(
bool initializeSucceeds,
Action<SessionDiagnosticsDataType>? onCreateDiagnostics = null)
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var serverMock = new Mock<IServerInternal>();
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<IDiagnosticsNodeManager>();
if (initializeSucceeds)
{
diagnosticsNodeManager
.Setup(m => m.CreateSessionDiagnosticsAsync(
It.IsAny<ServerSystemContext>(),
It.IsAny<SessionDiagnosticsDataType>(),
It.IsAny<NodeValueSimpleEventHandler>(),
It.IsAny<SessionSecurityDiagnosticsDataType>(),
It.IsAny<NodeValueSimpleEventHandler>(),
It.IsAny<CancellationToken>()))
.Callback<ServerSystemContext, SessionDiagnosticsDataType, NodeValueSimpleEventHandler,
SessionSecurityDiagnosticsDataType, NodeValueSimpleEventHandler, CancellationToken>(
(_, diagnostics, _, _, _, _) => onCreateDiagnostics?.Invoke(diagnostics))
.Returns(new ValueTask<NodeId>(new NodeId(5001, 1)));
}
else
{
diagnosticsNodeManager
.Setup(m => m.CreateSessionDiagnosticsAsync(
It.IsAny<ServerSystemContext>(),
It.IsAny<SessionDiagnosticsDataType>(),
It.IsAny<NodeValueSimpleEventHandler>(),
It.IsAny<SessionSecurityDiagnosticsDataType>(),
It.IsAny<NodeValueSimpleEventHandler>(),
It.IsAny<CancellationToken>()))
.Returns(
new ValueTask<NodeId>(
Task.FromException<NodeId>(
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<ISession?> InvokeRestoreSessionAsync(
DistributedSessionManager manager,
NodeId authenticationToken,
OperationContext context)
{
MethodInfo method = typeof(DistributedSessionManager).GetMethod(
"RestoreSessionAsync",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var task = (ValueTask<ISession?>)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;
}
}
}