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

753 lines
33 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/
* ======================================================================*/
// IDE0230: byte-array literals below are opaque binary test vectors, not text; a
// UTF-8 "..."u8 literal would misrepresent their intent, so keep the explicit byte arrays.
#pragma warning disable IDE0230 // Use UTF-8 string literal
// 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.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using Opc.Ua.Redundancy;
using Opc.Ua.Redundancy.Server;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests.Redundancy
{
/// <summary>
/// Unit tests for <see cref="SharedKeyValueSessionStore"/>.
/// </summary>
[TestFixture]
[Category("Distributed")]
[Parallelizable(ParallelScope.All)]
public class SharedSessionStoreTests
{
private const ushort NamespaceIndex = 1;
private IServiceMessageContext m_context = null!;
[OneTimeSetUp]
public void OneTimeSetUp()
{
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var messageContext = ServiceMessageContext.CreateEmpty(telemetry);
messageContext.NamespaceUris.GetIndexOrAppend("urn:test:session");
m_context = messageContext;
}
[Test]
public async Task PutAndTryGetRoundTripsAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("tok-1");
await store.PutAsync(entry).ConfigureAwait(false);
SharedSessionEntry? loaded = await store.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false);
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.SessionId, Is.EqualTo(entry.SessionId));
Assert.That(loaded.AuthenticationToken, Is.EqualTo(entry.AuthenticationToken));
Assert.That(loaded.SessionName, Is.EqualTo(entry.SessionName));
Assert.That(loaded.CreatedAt, Is.EqualTo(entry.CreatedAt));
Assert.That(loaded.LastActivatedAt, Is.EqualTo(entry.LastActivatedAt));
Assert.That(loaded.SecretMaterial.ToArray(), Is.EqualTo(entry.SecretMaterial.ToArray()));
}
[Test]
public async Task FullEntryRoundTripsAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("tok-full");
await store.PutAsync(entry).ConfigureAwait(false);
SharedSessionEntry? loaded = await store.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false);
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.ServerNonce.ToArray(), Is.EqualTo(entry.ServerNonce.ToArray()));
Assert.That(loaded.ClientNonce.ToArray(), Is.EqualTo(entry.ClientNonce.ToArray()));
Assert.That(
loaded.ClientCertificateChain.ToArray(),
Is.EqualTo(entry.ClientCertificateChain.ToArray()));
Assert.That(loaded.SecurityPolicyUri, Is.EqualTo(entry.SecurityPolicyUri));
Assert.That(loaded.SecurityMode, Is.EqualTo(entry.SecurityMode));
Assert.That(loaded.EndpointUrl, Is.EqualTo(entry.EndpointUrl));
Assert.That(loaded.SessionTimeout, Is.EqualTo(entry.SessionTimeout));
Assert.That(loaded.SecurityStateVersion, Is.EqualTo(entry.SecurityStateVersion));
Assert.That(
loaded.OriginalClientChannelCertificate,
Is.EqualTo(entry.OriginalClientChannelCertificate));
Assert.That(loaded.ClientUserId, Is.EqualTo(entry.ClientUserId));
Assert.That(loaded.ClientUserTokenType, Is.EqualTo(entry.ClientUserTokenType));
Assert.That(
loaded.HasActivatedUserIdentity,
Is.EqualTo(entry.HasActivatedUserIdentity));
Assert.That(
loaded.ClientDescription.ApplicationUri,
Is.EqualTo(entry.ClientDescription.ApplicationUri));
}
[Test]
public async Task TryGetMissingReturnsNullAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry? loaded = await store.TryGetAsync(new NodeId("nope", NamespaceIndex)).ConfigureAwait(false);
Assert.That(loaded, Is.Null);
}
[Test]
public async Task RemoveDeletesEntryAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("tok-2");
await store.PutAsync(entry).ConfigureAwait(false);
bool removed = await store.RemoveAsync(entry.AuthenticationToken).ConfigureAwait(false);
SharedSessionEntry? loaded = await store.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false);
Assert.That(removed, Is.True);
Assert.That(loaded, Is.Null);
}
[Test]
public async Task SessionVisibleToOtherReplicaSharingStoreAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var active = new SharedKeyValueSessionStore(kv, m_context);
var standby = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("tok-3");
await active.PutAsync(entry).ConfigureAwait(false);
SharedSessionEntry? onStandby = await standby.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false);
Assert.That(onStandby, Is.Not.Null, "standby can reconnect the session using just the token");
Assert.That(onStandby!.SessionId, Is.EqualTo(entry.SessionId));
}
[Test]
public async Task ProtectedSessionRoundTripsAndIsEncryptedAtRestAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(21));
var store = new SharedKeyValueSessionStore(kv, m_context, protector);
SharedSessionEntry entry = NewEntry("tok-prot");
await store.PutAsync(entry).ConfigureAwait(false);
SharedSessionEntry? loaded = await store.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false);
// Secret material and the server nonce must not be persisted in cleartext.
(bool rawFound, ByteString raw) = await kv.TryGetAsync(
SharedKeyValueSessionStore.KeyFor(entry.AuthenticationToken)).ConfigureAwait(false);
Assert.That(rawFound, Is.True);
Assert.That(Contains(raw.ToArray(), entry.SecretMaterial.ToArray()), Is.False);
Assert.That(Contains(raw.ToArray(), entry.ServerNonce.ToArray()), Is.False);
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.SecretMaterial.ToArray(), Is.EqualTo(entry.SecretMaterial.ToArray()));
Assert.That(loaded.ServerNonce.ToArray(), Is.EqualTo(entry.ServerNonce.ToArray()));
}
[Test]
public async Task TamperedProtectedSessionIsRejectedFailClosedAsync()
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(22));
var store = new SharedKeyValueSessionStore(kv, m_context, protector);
SharedSessionEntry entry = NewEntry("tok-tamper");
await store.PutAsync(entry).ConfigureAwait(false);
await kv.SetAsync(
SharedKeyValueSessionStore.KeyFor(entry.AuthenticationToken),
ByteString.From(new byte[] { 1, 2, 3, 4, 5, 6 })).ConfigureAwait(false);
SharedSessionEntry? loaded = await store.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false);
Assert.That(loaded, Is.Null);
}
[TestCase(false)]
[TestCase(true)]
public async Task ProtectedSessionRejectsTransplantOrEmptyContextAsync(bool emptyContext)
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(23));
var store = new SharedKeyValueSessionStore(kv, m_context, protector);
SharedSessionEntry source = NewEntry("source");
SharedSessionEntry target = emptyContext ? source : NewEntry("target");
await store.PutAsync(source).ConfigureAwait(false);
string sourceKey = SharedKeyValueSessionStore.KeyFor(source.AuthenticationToken);
string targetKey = SharedKeyValueSessionStore.KeyFor(target.AuthenticationToken);
(bool found, ByteString record) = await kv.TryGetAsync(sourceKey).ConfigureAwait(false);
Assert.That(found, Is.True);
ByteString invalid = record;
if (emptyContext)
{
Assert.That(protector.TryUnprotectOwned(
RecordProtectionContext.Create("session", sourceKey), record, out byte[] plaintext), Is.True);
try
{
invalid = protector.Protect(default, ByteString.From(plaintext));
}
finally
{
CryptoUtils.ZeroMemory(plaintext);
}
}
await kv.SetAsync(targetKey, invalid).ConfigureAwait(false);
Assert.That(await store.TryGetAsync(target.AuthenticationToken).ConfigureAwait(false), Is.Null);
await kv.SetAsync(sourceKey, record).ConfigureAwait(false);
SharedSessionEntry? retained = await store.TryGetAsync(source.AuthenticationToken).ConfigureAwait(false);
Assert.That(retained, Is.Not.Null);
Assert.That(retained!.AuthenticationToken, Is.EqualTo(source.AuthenticationToken));
Assert.That(retained.SecretMaterial, Is.EqualTo(source.SecretMaterial));
}
[Test]
public async Task CorruptSessionEntryIsRejectedAndLoggedAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var logger = new RecordingLogger<SharedKeyValueSessionStore>();
var store = new SharedKeyValueSessionStore(kv, m_context, null, logger);
var authenticationToken = new NodeId("tok-corrupt", NamespaceIndex);
string key = SharedKeyValueSessionStore.KeyFor(authenticationToken);
await kv.SetAsync(key, ByteString.From(new byte[] { 1, 2, 3, 4 })).ConfigureAwait(false);
SharedSessionEntry? loaded = await store
.TryGetAsync(authenticationToken)
.ConfigureAwait(false);
Assert.That(loaded, Is.Null);
Assert.That(logger.Level, Is.EqualTo(LogLevel.Warning));
Assert.That(logger.Message, Does.Contain(key));
Assert.That(logger.Exception, Is.Not.Null);
}
[Test]
public async Task LegacyEntryDecodesWithoutSecurityStateForFailClosedRestoreAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("tok-legacy");
await kv.SetAsync(
SharedKeyValueSessionStore.KeyFor(entry.AuthenticationToken),
EncodeLegacyEntry(entry)).ConfigureAwait(false);
SharedSessionEntry? loaded = await store
.TryGetAsync(entry.AuthenticationToken)
.ConfigureAwait(false);
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.SecurityStateVersion, Is.Zero);
Assert.That(loaded.OriginalClientChannelCertificate.IsNull, Is.True);
Assert.That(loaded.ClientUserId, Is.Null);
}
[Test]
public async Task VersionOneEntryDecodesForVersionTolerantFailClosedRestoreAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("tok-version-one");
await kv.SetAsync(
SharedKeyValueSessionStore.KeyFor(entry.AuthenticationToken),
EncodeVersionOneEntry(entry)).ConfigureAwait(false);
SharedSessionEntry? loaded = await store
.TryGetAsync(entry.AuthenticationToken)
.ConfigureAwait(false);
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.SecurityStateVersion, Is.EqualTo(1));
Assert.That(
loaded.OriginalClientChannelCertificate,
Is.EqualTo(entry.OriginalClientChannelCertificate));
Assert.That(loaded.ClientUserId, Is.EqualTo(entry.ClientUserId));
Assert.That(loaded.HasActivatedUserIdentity, Is.False);
}
/// <summary>
/// PR review 4168294597: the client certificate provenance is mirrored, so a
/// restored Session is not a trusted application when the original was not.
/// </summary>
[TestCase(true)]
[TestCase(false)]
public async Task ClientCertificateProvenanceRoundTripsAsync(bool validated)
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("tok-provenance-" + validated) with
{
ClientCertificateValidated = validated
};
await store.PutAsync(entry).ConfigureAwait(false);
SharedSessionEntry? loaded = await store.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false);
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.SecurityStateVersion, Is.EqualTo(SharedSessionEntry.CurrentSecurityStateVersion));
Assert.That(loaded.ClientCertificateValidated, Is.EqualTo(validated));
}
/// <summary>
/// PR review 4168294597: an entry written before the provenance was mirrored
/// (security state version 3) still decodes, with the certificate treated as
/// not validated (fail closed).
/// </summary>
[Test]
public async Task VersionThreeEntryDecodesWithAnUnvalidatedCertificateAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("tok-version-three") with
{
SecurityStateVersion = 3,
ClientCertificateValidated = true
};
await store.PutAsync(entry).ConfigureAwait(false);
SharedSessionEntry? loaded = await store.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false);
Assert.That(loaded, Is.Not.Null);
Assert.That(loaded!.SecurityStateVersion, Is.EqualTo(3));
Assert.That(loaded.HasActivatedUserIdentity, Is.True);
Assert.That(loaded.ClientUserId, Is.EqualTo(entry.ClientUserId));
Assert.That(loaded.ClientCertificateValidated, Is.False);
}
[Test]
public async Task KeyspaceDoesNotExposeRawTokenAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("tok-keyspace");
await store.PutAsync(entry).ConfigureAwait(false);
string tokenText = entry.AuthenticationToken.ToString();
await foreach (KeyValuePair<string, ByteString> pair in kv.ScanAsync("session/"))
{
Assert.That(
pair.Key.IndexOf(tokenText, StringComparison.Ordinal),
Is.LessThan(0),
"the raw authentication token must not appear in the keyspace");
}
// The legacy raw-token key does not exist; the hashed key resolves the entry.
(bool legacyFound, _) = await kv.TryGetAsync("session/" + tokenText).ConfigureAwait(false);
Assert.That(legacyFound, Is.False);
Assert.That(await store.TryGetAsync(entry.AuthenticationToken).ConfigureAwait(false), Is.Not.Null);
}
/// <summary>
/// Issue #4627 RS-1/RS-2/RS-4/RS-8: the version 5 state (liveness heartbeat, owner,
/// original server certificate, EphemeralKey requirement) round-trips.
/// </summary>
[Test]
public async Task VersionFiveStateRoundTripsAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("v5") with
{
LastContactAt = DateTimeUtc.From(new DateTime(2026, 10, 8, 12, 0, 0, DateTimeKind.Utc)),
OwnerId = "replica-a",
ServerCertificate = ByteString.From(CreateBytes(48, 9)),
UserTokenRequiresEphemeralKey = true
};
await store.PutAsync(entry);
SharedSessionEntry? read = await store.TryGetAsync(entry.AuthenticationToken);
Assert.That(read, Is.Not.Null);
Assert.That(read!.SecurityStateVersion, Is.EqualTo(5u));
Assert.That(read.LastContactAt, Is.EqualTo(entry.LastContactAt));
Assert.That(read.OwnerId, Is.EqualTo("replica-a"));
Assert.That(read.ServerCertificate, Is.EqualTo(entry.ServerCertificate));
Assert.That(read.UserTokenRequiresEphemeralKey, Is.True);
}
[Test]
public async Task VersionFourEntryDecodesWithoutOwnerAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("v4") with
{
SecurityStateVersion = 4,
OwnerId = "ignored",
ClientCertificateValidated = true
};
await store.PutAsync(entry);
SharedSessionEntry? read = await store.TryGetAsync(entry.AuthenticationToken);
Assert.That(read, Is.Not.Null);
Assert.That(read!.SecurityStateVersion, Is.EqualTo(4u));
Assert.That(read.ClientCertificateValidated, Is.True);
Assert.That(read.OwnerId, Is.Null);
Assert.That(read.LastContactAt.IsNull, Is.True);
}
/// <summary>
/// Issue #4627 RS-2: a conditional replace or remove succeeds only against the
/// version it was read from, so a stale replica cannot overwrite or delete the
/// entry another replica has updated meanwhile.
/// </summary>
[TestCase(false)]
[TestCase(true)]
public async Task ConditionalWritesFailAfterAConcurrentChangeAsync(bool protect)
{
using var kv = new InMemorySharedKeyValueStore();
using var protector = new AesCbcHmacRecordProtector(MakeKey(3));
var store = new SharedKeyValueSessionStore(kv, m_context, protect ? protector : null);
SharedSessionEntry entry = NewEntry("conditional");
await store.PutAsync(entry);
SharedSessionEntry staleRead = (await store.TryGetAsync(entry.AuthenticationToken))!;
SharedSessionEntry freshRead = (await store.TryGetAsync(entry.AuthenticationToken))!;
Assert.That(
await store.TryReplaceAsync(freshRead, freshRead with { OwnerId = "replica-b" }),
Is.True);
Assert.That(
await store.TryReplaceAsync(staleRead, staleRead with { OwnerId = "replica-a" }),
Is.False);
Assert.That(await store.TryRemoveAsync(staleRead), Is.False);
Assert.That((await store.TryGetAsync(entry.AuthenticationToken))!.OwnerId, Is.EqualTo("replica-b"));
SharedSessionEntry current = (await store.TryGetAsync(entry.AuthenticationToken))!;
Assert.That(await store.TryRemoveAsync(current), Is.True);
Assert.That(await store.TryGetAsync(entry.AuthenticationToken), Is.Null);
}
[Test]
public async Task ConditionalWriteRejectsAnEntryNotReadFromTheStoreAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("unread");
await store.PutAsync(entry);
Assert.That(await store.TryReplaceAsync(entry, entry with { OwnerId = "x" }), Is.False);
Assert.That(await store.TryRemoveAsync(entry), Is.False);
}
[Test]
public async Task ReplacedEntryCanBeReplacedAgainAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
SharedSessionEntry entry = NewEntry("chained");
await store.PutAsync(entry);
SharedSessionEntry read = (await store.TryGetAsync(entry.AuthenticationToken))!;
SharedSessionEntry first = read with { OwnerId = "a" };
Assert.That(await store.TryReplaceAsync(read, first), Is.True);
Assert.That(await store.TryReplaceAsync(first, first with { OwnerId = "b" }), Is.True);
Assert.That((await store.TryGetAsync(entry.AuthenticationToken))!.OwnerId, Is.EqualTo("b"));
}
/// <summary>
/// The CRDT gossip store has no compare-and-swap; conditional writes fall back
/// to a compare followed by a write.
/// </summary>
[Test]
public async Task ConditionalWritesWorkOverAStoreWithoutCompareAndSwapAsync()
{
using var inner = new InMemorySharedKeyValueStore();
var kv = new NoCompareAndSwapStore(inner);
var store = new SharedKeyValueSessionStore(kv, m_context, NullRecordProtector.Instance);
SharedSessionEntry entry = NewEntry("no-cas");
await store.PutAsync(entry);
SharedSessionEntry stale = (await store.TryGetAsync(entry.AuthenticationToken))!;
SharedSessionEntry read = (await store.TryGetAsync(entry.AuthenticationToken))!;
Assert.That(await store.TryReplaceAsync(read, read with { OwnerId = "b" }), Is.True);
Assert.That(await store.TryReplaceAsync(stale, stale with { OwnerId = "a" }), Is.False);
SharedSessionEntry current = (await store.TryGetAsync(entry.AuthenticationToken))!;
Assert.That(await store.TryRemoveAsync(current), Is.True);
Assert.That(await store.TryGetAsync(entry.AuthenticationToken), Is.Null);
}
[Test]
public async Task EnumerateReturnsTheStoredEntriesAsync()
{
using var kv = new InMemorySharedKeyValueStore();
var store = new SharedKeyValueSessionStore(kv, m_context);
await store.PutAsync(NewEntry("first"));
await store.PutAsync(NewEntry("second"));
await kv.SetAsync("session/garbage", ByteString.From(new byte[] { 1, 2, 3 }));
await kv.SetAsync("other/key", ByteString.From(new byte[] { 4 }));
var tokens = new List<string>();
await foreach (SharedSessionEntry entry in store.EnumerateAsync())
{
tokens.Add(entry.AuthenticationToken.ToString());
Assert.That(await store.TryRemoveAsync(entry), Is.True, "enumerated entries are conditional-write capable");
}
Assert.That(tokens, Has.Count.EqualTo(2));
}
/// <summary>
/// Issue #4627 RS-7: the public constructor no longer silently writes session
/// secrets in cleartext to an external store.
/// </summary>
[Test]
public void ExternalStoreWithoutProtectorIsRejected()
{
using var inner = new InMemorySharedKeyValueStore();
var external = new NoCompareAndSwapStore(inner);
Assert.That(
() => new SharedKeyValueSessionStore(external, m_context),
Throws.InvalidOperationException);
Assert.That(
() => new SharedKeyValueSessionStore(external, m_context, NullRecordProtector.Instance),
Throws.Nothing);
Assert.That(
() => new SharedKeyValueSessionStore(inner, m_context),
Throws.Nothing);
}
/// <summary>
/// An external store (not in memory) that does not support compare-and-swap,
/// like the CRDT gossip store.
/// </summary>
internal sealed class NoCompareAndSwapStore : ISharedKeyValueStore
{
public NoCompareAndSwapStore(InMemorySharedKeyValueStore inner)
{
m_inner = inner;
}
public ValueTask<(bool Found, ByteString Value)> TryGetAsync(string key, System.Threading.CancellationToken ct = default)
{
return m_inner.TryGetAsync(key, ct);
}
public ValueTask SetAsync(string key, ByteString value, System.Threading.CancellationToken ct = default)
{
return m_inner.SetAsync(key, value, ct);
}
public ValueTask<bool> CompareAndSwapAsync(
string key,
ByteString expected,
ByteString value,
System.Threading.CancellationToken ct = default)
{
throw new NotSupportedException();
}
public ValueTask<bool> DeleteAsync(string key, System.Threading.CancellationToken ct = default)
{
return m_inner.DeleteAsync(key, ct);
}
public IAsyncEnumerable<KeyValuePair<string, ByteString>> ScanAsync(
string keyPrefix,
System.Threading.CancellationToken ct = default)
{
return m_inner.ScanAsync(keyPrefix, ct);
}
public IAsyncEnumerable<KeyValueChange> WatchAsync(
string keyPrefix,
System.Threading.CancellationToken ct = default)
{
return m_inner.WatchAsync(keyPrefix, ct);
}
private readonly InMemorySharedKeyValueStore m_inner;
}
private static bool Contains(byte[] haystack, byte[] needle)
{
for (int i = 0; i + needle.Length <= haystack.Length; i++)
{
bool match = true;
for (int j = 0; j < needle.Length; j++)
{
if (haystack[i + j] != needle[j])
{
match = false;
break;
}
}
if (match)
{
return true;
}
}
return false;
}
private static byte[] MakeKey(byte seed)
{
byte[] key = new byte[32];
for (int i = 0; i < key.Length; i++)
{
key[i] = (byte)(seed + i);
}
return key;
}
private static SharedSessionEntry NewEntry(string token)
{
return new SharedSessionEntry
{
SessionId = new NodeId(42, NamespaceIndex),
AuthenticationToken = new NodeId(token, NamespaceIndex),
SessionName = "Session " + token,
CreatedAt = DateTimeUtc.Now,
LastActivatedAt = DateTimeUtc.Now,
ServerNonce = ByteString.From(CreateBytes(32, 40)),
ClientNonce = ByteString.From(new byte[] { 1, 2, 3, 4 }),
ClientCertificateChain = ByteString.From(new byte[] { 5, 6, 7, 8, 9 }),
SecurityStateVersion = SharedSessionEntry.CurrentSecurityStateVersion,
OriginalClientChannelCertificate = ByteString.From(CreateBytes(64, 5)),
ClientUserId = "ExactUser",
ClientUserTokenType = UserTokenType.UserName,
HasActivatedUserIdentity = true,
SecurityPolicyUri = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256",
SecurityMode = (int)MessageSecurityMode.SignAndEncrypt,
EndpointUrl = "opc.tcp://localhost:4840",
SessionTimeout = 60000,
ClientDescription = new ApplicationDescription
{
ApplicationName = new LocalizedText("Test Client"),
ApplicationUri = "urn:test:client:" + token,
ApplicationType = ApplicationType.Client
},
SecretMaterial = ByteString.From(new byte[] { 10, 20, 30 })
};
}
private ByteString EncodeLegacyEntry(SharedSessionEntry entry)
{
using var encoder = new BinaryEncoder(m_context);
WriteLegacyFields(encoder, entry);
return ByteString.From(encoder.CloseAndReturnBuffer());
}
private ByteString EncodeVersionOneEntry(SharedSessionEntry entry)
{
using var encoder = new BinaryEncoder(m_context);
WriteLegacyFields(encoder, entry);
encoder.WriteUInt32(null, 1);
encoder.WriteByteString(null, entry.OriginalClientChannelCertificate);
encoder.WriteString(null, entry.ClientUserId);
return ByteString.From(encoder.CloseAndReturnBuffer());
}
private static void WriteLegacyFields(
BinaryEncoder encoder,
SharedSessionEntry entry)
{
encoder.WriteNodeId(null, entry.SessionId);
encoder.WriteNodeId(null, entry.AuthenticationToken);
encoder.WriteString(null, entry.SessionName);
encoder.WriteInt64(null, entry.CreatedAt);
encoder.WriteInt64(null, entry.LastActivatedAt);
encoder.WriteByteString(null, entry.ServerNonce);
encoder.WriteByteString(null, entry.ClientNonce);
encoder.WriteByteString(null, entry.ClientCertificateChain);
encoder.WriteString(null, entry.SecurityPolicyUri);
encoder.WriteInt32(null, entry.SecurityMode);
encoder.WriteString(null, entry.EndpointUrl);
encoder.WriteDouble(null, entry.SessionTimeout);
encoder.WriteEncodeable(null, entry.ClientDescription);
encoder.WriteByteString(null, entry.SecretMaterial);
}
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;
}
private sealed class RecordingLogger<T> : ILogger<T>
{
public LogLevel? Level { get; private set; }
public string? Message { get; private set; }
public Exception? Exception { get; private set; }
public IDisposable BeginScope<TState>(TState state)
where TState : notnull
{
return NullScope.Instance;
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Level = logLevel;
Message = formatter(state, exception);
Exception = exception;
}
}
private sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose()
{
}
}
}
}