Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/SessionOriginalServerCertificateTests.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

205 lines
9.0 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Tests;
using ServerSession = Opc.Ua.Server.Session;
namespace Opc.Ua.Server.Tests
{
/// <summary>
/// Issue #4627 RS-8: a session restored on another replica of a non-transparent
/// redundant server set accepts a client signature over the certificate of the server
/// that created the session, since every replica has its own ApplicationUri and
/// certificate (OPC 10000-4 6.6.2.4.1), as well as one over its own certificate.
/// </summary>
[TestFixture]
[Category("Session")]
[Parallelizable]
public class SessionOriginalServerCertificateTests
{
private static readonly ICertificateFactory s_factory = DefaultCertificateFactory.Instance;
private ITelemetryContext m_telemetry = null!;
private Mock<IServerInternal> m_serverMock = null!;
private Certificate m_localServerCertificate = null!;
private Certificate m_originalServerCertificate = null!;
private Certificate m_clientCertificate = null!;
[OneTimeSetUp]
public void OneTimeSetUp()
{
m_localServerCertificate = s_factory.CreateCertificate("CN=StandbyReplica").CreateForRSA();
m_originalServerCertificate = s_factory.CreateCertificate("CN=ActiveReplica").CreateForRSA();
m_clientCertificate = s_factory.CreateCertificate("CN=FailoverClient").CreateForRSA();
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
m_localServerCertificate?.Dispose();
m_originalServerCertificate?.Dispose();
m_clientCertificate?.Dispose();
}
[SetUp]
public void SetUp()
{
m_telemetry = NUnitTelemetryContext.Create();
m_serverMock = new Mock<IServerInternal>();
m_serverMock.Setup(s => s.Telemetry).Returns(m_telemetry);
m_serverMock.Setup(s => s.NamespaceUris).Returns(new NamespaceTable());
m_serverMock.Setup(s => s.SessionManager).Returns(new Mock<ISessionManager>().Object);
}
[Test]
public async Task SignatureOverOriginalServerCertificateIsAcceptedForRestoredSessionAsync()
{
using ServerSession session = CreateSession(out byte[] serverNonce, out OperationContext context);
session.OriginalServerCertificate = m_originalServerCertificate.RawData.ToByteString();
SignatureData signature = Sign(m_originalServerCertificate, serverNonce);
(IUserIdentityTokenHandler handler, _) = await session.ValidateBeforeActivateAsync(
context, signature, default, new SignatureData(), CancellationToken.None).ConfigureAwait(false);
Assert.That(handler.TokenType, Is.EqualTo(UserTokenType.Anonymous));
}
[Test]
public async Task SignatureOverOwnServerCertificateIsStillAcceptedForRestoredSessionAsync()
{
using ServerSession session = CreateSession(out byte[] serverNonce, out OperationContext context);
session.OriginalServerCertificate = m_originalServerCertificate.RawData.ToByteString();
SignatureData signature = Sign(m_localServerCertificate, serverNonce);
(IUserIdentityTokenHandler handler, _) = await session.ValidateBeforeActivateAsync(
context, signature, default, new SignatureData(), CancellationToken.None).ConfigureAwait(false);
Assert.That(handler.TokenType, Is.EqualTo(UserTokenType.Anonymous));
}
/// <summary>
/// The original server may have returned its complete chain in CreateSession; a
/// client signs either that blob or the leaf certificate.
/// </summary>
[TestCase(true)]
[TestCase(false)]
public async Task SignatureOverOriginalServerCertificateChainOrLeafIsAcceptedAsync(bool signChain)
{
using ServerSession session = CreateSession(out byte[] serverNonce, out OperationContext context);
using var chain = new CertificateCollection { m_originalServerCertificate, m_localServerCertificate };
byte[] chainBlob = Utils.CreateCertificateChainBlob(chain);
session.OriginalServerCertificate = chainBlob.ToByteString();
SignatureData signature = signChain
? Sign(chainBlob, serverNonce)
: Sign(m_originalServerCertificate, serverNonce);
(IUserIdentityTokenHandler handler, _) = await session.ValidateBeforeActivateAsync(
context, signature, default, new SignatureData(), CancellationToken.None).ConfigureAwait(false);
Assert.That(handler.TokenType, Is.EqualTo(UserTokenType.Anonymous));
}
[Test]
public void SignatureOverAnotherServerCertificateIsRejectedWithoutOriginalCertificate()
{
using ServerSession session = CreateSession(out byte[] serverNonce, out OperationContext context);
SignatureData signature = Sign(m_originalServerCertificate, serverNonce);
ServiceResultException? ex = Assert.ThrowsAsync<ServiceResultException>(
async () => await session.ValidateBeforeActivateAsync(
context, signature, default, new SignatureData(), CancellationToken.None).ConfigureAwait(false));
Assert.That(ex!.StatusCode, Is.EqualTo(StatusCodes.BadApplicationSignatureInvalid));
}
private ServerSession CreateSession(out byte[] serverNonce, out OperationContext context)
{
var endpoint = new EndpointDescription
{
EndpointUrl = "opc.tcp://localhost:4840/Standby",
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
UserIdentityTokens = new ArrayOf<UserTokenPolicy>(new[]
{
new UserTokenPolicy { PolicyId = "anon", TokenType = UserTokenType.Anonymous }
})
};
context = new OperationContext(
new RequestHeader(),
new SecureChannelContext("channel-1", endpoint, RequestEncoding.Binary),
RequestType.ActivateSession,
RequestLifetime.None);
serverNonce = Nonce.CreateRandomNonceData(32);
return new ServerSession(
context,
m_serverMock.Object,
m_localServerCertificate,
new NodeId(Guid.NewGuid()),
ByteString.Empty,
Nonce.CreateNonce(SecurityPolicies.None, serverNonce),
"RestoredSession",
new ApplicationDescription { ApplicationUri = "urn:failover:client" },
endpoint.EndpointUrl,
m_clientCertificate.AddRef(),
[],
60_000,
10,
10);
}
private SignatureData Sign(Certificate serverCertificate, byte[] serverNonce)
{
return Sign(serverCertificate.RawData, serverNonce);
}
private SignatureData Sign(byte[] serverCertificate, byte[] serverNonce)
{
SecurityPolicyInfo policy = SecurityPolicies.Default.GetInfo(SecurityPolicies.Basic256Sha256)!;
byte[] dataToSign = policy.GetClientSignatureData(
null,
serverNonce,
serverCertificate,
null,
null,
[]);
return SecurityPolicies.Default.CreateSignatureData(policy, m_clientCertificate, dataToSign);
}
}
}