mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-27 04:59:55 +08:00
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>
205 lines
9.0 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|