Files
UA-.NETStandard/tests/Opc.Ua.PubSub.Tests/Security/SecurityTokenWindowTests.cs
Marc Schier 9ada62baa4 Harden PubSub replay, Action, and DTLS security (OPC 10000-14 / RFC 9147) (#4044)
# Description

This draft is the focused PubSub/UDP/DTLS security split from #4021.

The changes:

- enforce replay commit ordering: reject UADP messages that fail
signature or required-security-mode checks before mutating replay state,
and advance the DTLS replay window only after record authentication
succeeds;
- scope UADP sequence windows by `(PublisherId, WriterGroupId,
SecurityTokenId)` while retaining global per-key nonce uniqueness across
all publisher and writer-group scopes;
- serialize outbound DTLS record sequence allocation and cryptographic
use, reconstruct truncated DTLS sequence numbers, and reject epoch
sequence exhaustion;
- keep unsecured Action opt-in on each registered responder instead of
promoting it to connection-wide policy;
- pin connection-ID-less DTLS associations to the endpoint authenticated
by the handshake and drop records from other peers before replay
processing.

These changes implement the replay and nonce requirements in [OPC
10000-14
§7.2.4.4.2](https://reference.opcfoundation.org/Core/Part14/v105/docs/7.2.4.4.2)
and the DTLS security considerations in [RFC 9147
§11](https://www.rfc-editor.org/rfc/rfc9147.html#section-11).

## Validation

- `git diff --check origin/master...HEAD` — passed.
- `dotnet test tests\Opc.Ua.PubSub.Tests\Opc.Ua.PubSub.Tests.csproj
--configuration Release --framework net10.0 -p:CustomTestTarget=net10.0`
— 1,324 passed, 1 skipped.
- `dotnet test
tests\Opc.Ua.PubSub.Udp.Tests\Opc.Ua.PubSub.Udp.Tests.csproj
--configuration Release --framework net10.0 -p:CustomTestTarget=net10.0`
— 227 passed.
- `dotnet test tests\Opc.Ua.PubSub.Tests\Opc.Ua.PubSub.Tests.csproj
--configuration Release --framework net48 -p:CustomTestTarget=net48` —
1,324 passed, 1 skipped.
- `dotnet test
tests\Opc.Ua.PubSub.Udp.Tests\Opc.Ua.PubSub.Udp.Tests.csproj
--configuration Release --framework net48 -p:CustomTestTarget=net48` —
175 passed, 4 skipped.

Direct regression coverage includes forged-record replay poisoning,
scoped sequence windows and nonce reuse, concurrent sequence
serialization, sequence exhaustion, per-responder unsecured Action
isolation, and authenticated-peer pinning.

## Related Issues

- Split from #4021.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce new build or
analyzer warnings.
- [ ] I ran all tests locally using the UA.slnx solution against at
least .NET Framework and .NET 10, and all passed.
- [ ] I fixed all failing and flaky tests in the CI pipelines and all
CodeQL warnings.
- [ ] I have addressed all PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 19:54:45 +02:00

415 lines
16 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 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.Collections.Concurrent;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.PubSub.Encoding;
using Opc.Ua.PubSub.Security;
namespace Opc.Ua.PubSub.Tests.Security
{
/// <summary>
/// Tests for <see cref="SecurityTokenWindow"/>.
/// </summary>
[TestFixture]
[TestSpec("7.2.4.4.3.1", Summary = "PubSub replay-window")]
public class SecurityTokenWindowTests
{
private static readonly uint[] s_expectedTokenIds = [1U, 7U];
private static byte[] MakeNonce(byte seed)
{
byte[] nonce = new byte[12];
for (int i = 0; i < nonce.Length; i++)
{
nonce[i] = (byte)(seed + i);
}
return nonce;
}
[Test]
public void TryAccept_AcceptsFirstMessageForRegisteredToken()
{
var window = new SecurityTokenWindow();
window.RegisterToken(1U);
Assert.That(window.TryAccept(1U, 1UL, MakeNonce(1)), Is.True);
}
[Test]
public void TryAccept_RejectsUnknownToken()
{
var window = new SecurityTokenWindow();
Assert.That(window.TryAccept(99U, 1UL, MakeNonce(1)), Is.False);
}
[Test]
public void TryAccept_RejectsReplayedSequenceNumber()
{
var window = new SecurityTokenWindow();
window.RegisterToken(1U);
Assert.Multiple(() =>
{
Assert.That(window.TryAccept(1U, 1UL, MakeNonce(1)), Is.True);
Assert.That(window.TryAccept(1U, 1UL, MakeNonce(2)), Is.False);
});
}
[Test]
public void TryAccept_RejectsReusedNonce()
{
var window = new SecurityTokenWindow();
window.RegisterToken(1U);
byte[] nonce = MakeNonce(7);
Assert.Multiple(() =>
{
Assert.That(window.TryAccept(1U, 1UL, nonce), Is.True);
Assert.That(window.TryAccept(1U, 2UL, nonce), Is.False);
});
}
[Test]
public void TryAccept_AcceptsDifferentTokenWithSameSequence()
{
var window = new SecurityTokenWindow();
window.RegisterToken(1U);
window.RegisterToken(2U);
byte[] nonce = MakeNonce(1);
Assert.Multiple(() =>
{
Assert.That(window.TryAccept(1U, 5UL, nonce), Is.True);
Assert.That(window.TryAccept(2U, 5UL, nonce), Is.True);
});
}
[Test]
public void ScopedReplayRejectsNonceReuseGloballyAcrossPublishersAndWriterGroups()
{
const uint tokenId = 1U;
var window = new SecurityTokenWindow(historySize: 4);
window.RegisterToken(tokenId);
var scopedWindow = (IScopedSecurityTokenWindow)window;
PublisherId publisherA = PublisherId.FromUInt32(100U);
PublisherId publisherB = PublisherId.FromUInt32(200U);
byte[] reusedNonce = MakeNonce(1);
Assert.That(
scopedWindow.TryAccept(publisherA, 1, tokenId, 1, reusedNonce),
Is.True);
for (ulong sequenceNumber = 2; sequenceNumber <= 8; sequenceNumber++)
{
Assert.That(
scopedWindow.TryAccept(
publisherA,
1,
tokenId,
sequenceNumber,
MakeNonce((byte)(sequenceNumber + 20))),
Is.True);
}
Assert.Multiple(() =>
{
Assert.That(
scopedWindow.TryAccept(publisherB, 1, tokenId, 1, reusedNonce),
Is.False);
Assert.That(
scopedWindow.TryAccept(publisherA, 2, tokenId, 1, reusedNonce),
Is.False);
Assert.That(
scopedWindow.TryAccept(publisherB, 1, tokenId, 1, MakeNonce(50)),
Is.True);
Assert.That(
scopedWindow.TryAccept(publisherA, 2, tokenId, 1, MakeNonce(51)),
Is.True);
});
}
[Test]
public void RetireToken_RejectsSubsequentMessages()
{
var window = new SecurityTokenWindow();
window.RegisterToken(1U);
window.RetireToken(1U);
Assert.That(window.TryAccept(1U, 1UL, MakeNonce(1)), Is.False);
}
[Test]
public void RetireTokenClearsScopedReplayStateAndKeepsOtherTokens()
{
const uint retiredToken = 1U;
const uint survivingToken = 7U;
var window = new SecurityTokenWindow(historySize: 4);
window.RegisterToken(retiredToken);
window.RegisterToken(survivingToken);
var scopedWindow = (IScopedSecurityTokenWindow)window;
PublisherId publisherA = PublisherId.FromUInt32(100U);
PublisherId publisherB = PublisherId.FromUInt32(200U);
// Populate two publisher/writer-group scopes on the token that is
// about to be retired, plus one scope on a token that survives.
Assert.Multiple(() =>
{
Assert.That(
scopedWindow.TryAccept(publisherA, 1, retiredToken, 5, MakeNonce(10)),
Is.True);
Assert.That(
scopedWindow.TryAccept(publisherB, 2, retiredToken, 5, MakeNonce(20)),
Is.True);
Assert.That(
scopedWindow.TryAccept(publisherA, 1, survivingToken, 5, MakeNonce(30)),
Is.True);
});
window.RetireToken(retiredToken);
Assert.Multiple(() =>
{
Assert.That(window.RegisteredTokens, Is.EquivalentTo(new[] { survivingToken }));
// The retired token no longer accepts anything.
Assert.That(
scopedWindow.TryAccept(publisherA, 1, retiredToken, 5, MakeNonce(10)),
Is.False);
// The surviving token keeps its committed sequence, so replaying
// sequence 5 on the retained scope is still rejected.
Assert.That(
scopedWindow.TryAccept(publisherA, 1, survivingToken, 5, MakeNonce(31)),
Is.False);
});
}
[Test]
public void Reset_ClearsAllState()
{
var window = new SecurityTokenWindow();
window.RegisterToken(1U);
Assert.That(window.TryAccept(1U, 1UL, MakeNonce(1)), Is.True);
window.Reset();
Assert.Multiple(() =>
{
Assert.That(window.RegisteredTokens, Is.Empty);
Assert.That(window.TryAccept(1U, 1UL, MakeNonce(1)), Is.False);
});
}
[Test]
public void TryAccept_RejectsReplayedSequenceAfterWindowAdvancesPastIt()
{
// Monotonic window: a sequence that has fallen below the
// lower edge of the window is permanently rejected (no
// eviction-replay).
var window = new SecurityTokenWindow(historySize: 4);
window.RegisterToken(1U);
for (ulong seq = 1; seq <= 8; seq++)
{
Assert.That(
window.TryAccept(1U, seq, MakeNonce((byte)seq)),
Is.True,
$"seq {seq} should be accepted");
}
// seq 1 is now far below (highest - historySize) and must
// stay rejected even with a fresh, never-seen nonce.
Assert.That(window.TryAccept(1U, 1UL, MakeNonce(80)), Is.False);
}
[Test]
public void TryAccept_RejectsReplayAfterMoreThanHistorySizeNewerMessages()
{
const int historySize = 8;
var window = new SecurityTokenWindow(historySize);
window.RegisterToken(1U);
byte[] capturedNonce = MakeNonce(3);
Assert.That(window.TryAccept(1U, 5UL, capturedNonce), Is.True);
// Advance the window well past the captured sequence.
for (ulong seq = 6; seq <= 5 + (historySize * 4); seq++)
{
Assert.That(
window.TryAccept(1U, seq, MakeNonce((byte)(seq + 100))),
Is.True);
}
// Replaying the captured frame (same sequence + same nonce)
// is rejected.
Assert.That(window.TryAccept(1U, 5UL, capturedNonce), Is.False);
}
[Test]
public void TryAccept_AcceptsOutOfOrderWithinWindow()
{
var window = new SecurityTokenWindow(historySize: 16);
window.RegisterToken(1U);
Assert.Multiple(() =>
{
Assert.That(window.TryAccept(1U, 10UL, MakeNonce(10)), Is.True);
// Older but still inside the window.
Assert.That(window.TryAccept(1U, 7UL, MakeNonce(7)), Is.True);
Assert.That(window.TryAccept(1U, 9UL, MakeNonce(9)), Is.True);
// Duplicate of an already-accepted in-window sequence.
Assert.That(window.TryAccept(1U, 9UL, MakeNonce(99)), Is.False);
// Newer sequence advances the window.
Assert.That(window.TryAccept(1U, 11UL, MakeNonce(11)), Is.True);
});
}
[Test]
public void TryAccept_HandlesSixteenBitBoundaryWithoutFalseRejection()
{
// The wire SequenceNumber crosses the 16-bit boundary; the
// widened monotonic counter keeps advancing so no spurious
// wrap rejection occurs around 0xFFFF.
var window = new SecurityTokenWindow(historySize: 64);
window.RegisterToken(1U);
Assert.Multiple(() =>
{
Assert.That(window.TryAccept(1U, 0xFFFEUL, MakeNonce(1)), Is.True);
Assert.That(window.TryAccept(1U, 0xFFFFUL, MakeNonce(2)), Is.True);
Assert.That(window.TryAccept(1U, 0x10000UL, MakeNonce(3)), Is.True);
Assert.That(window.TryAccept(1U, 0x10001UL, MakeNonce(4)), Is.True);
// Duplicate at the boundary is rejected.
Assert.That(window.TryAccept(1U, 0xFFFFUL, MakeNonce(5)), Is.False);
});
}
[Test]
public void TryAccept_HandlesLargeForwardJump()
{
var window = new SecurityTokenWindow(historySize: 8);
window.RegisterToken(1U);
Assert.Multiple(() =>
{
Assert.That(window.TryAccept(1U, 1UL, MakeNonce(1)), Is.True);
// Jump far beyond the window width — clears the bitmap.
Assert.That(window.TryAccept(1U, 1_000_000UL, MakeNonce(2)), Is.True);
// The old sequence is now ancient and stays rejected.
Assert.That(window.TryAccept(1U, 1UL, MakeNonce(3)), Is.False);
// A duplicate of the new highest is rejected.
Assert.That(window.TryAccept(1U, 1_000_000UL, MakeNonce(4)), Is.False);
});
}
[Test]
public void Constructor_RejectsNonPositiveHistorySize()
{
Assert.That(
() => new SecurityTokenWindow(historySize: 0),
Throws.TypeOf<ArgumentOutOfRangeException>());
Assert.That(
() => new SecurityTokenWindow(historySize: -1),
Throws.TypeOf<ArgumentOutOfRangeException>());
}
[Test]
public void ConstructorRejectsNonPositiveNonceFilterSize()
{
Assert.That(
() => new SecurityTokenWindow(
historySize: 1024,
timeProvider: null,
nonceFilterSizeInBytes: 0),
Throws.TypeOf<ArgumentOutOfRangeException>());
Assert.That(
() => new SecurityTokenWindow(
historySize: 1024,
timeProvider: null,
nonceFilterSizeInBytes: -1),
Throws.TypeOf<ArgumentOutOfRangeException>());
}
[Test]
public void Properties_ReflectConfiguration()
{
TimeProvider clock = TimeProvider.System;
var window = new SecurityTokenWindow(
historySize: 16,
timeProvider: clock,
nonceFilterSizeInBytes: 2048);
Assert.Multiple(() =>
{
Assert.That(window.HistorySize, Is.EqualTo(16));
Assert.That(window.TimeProvider, Is.SameAs(clock));
Assert.That(window.NonceFilterSizeInBytes, Is.EqualTo(2048));
});
}
[Test]
public void RegisteredTokens_ReturnsRegisteredIds()
{
var window = new SecurityTokenWindow();
window.RegisterToken(1U);
window.RegisterToken(7U);
Assert.That(window.RegisteredTokens, Is.EquivalentTo(s_expectedTokenIds));
}
[Test]
public void RegisterToken_IsIdempotent()
{
var window = new SecurityTokenWindow();
window.RegisterToken(1U);
window.RegisterToken(1U);
Assert.That(window.RegisteredTokens, Has.Count.EqualTo(1));
}
[Test]
public async Task TryAccept_ConcurrentInvocationDoesNotLoseEntries()
{
var window = new SecurityTokenWindow(historySize: 100_000);
window.RegisterToken(1U);
const int parallelism = 8;
const int perTask = 500;
var accepted = new ConcurrentBag<ulong>();
var workers = new Task[parallelism];
for (int t = 0; t < parallelism; t++)
{
int taskIndex = t;
workers[t] = Task.Run(() =>
{
for (int i = 0; i < perTask; i++)
{
ulong sequenceNumber = (ulong)((taskIndex * perTask) + i + 1);
byte[] nonce = new byte[12];
// Distinct nonce per (task, i) pair.
nonce[0] = (byte)taskIndex;
nonce[1] = (byte)(i & 0xff);
nonce[2] = (byte)((i >> 8) & 0xff);
if (window.TryAccept(1U, sequenceNumber, nonce))
{
accepted.Add(sequenceNumber);
}
}
});
}
await Task.WhenAll(workers).ConfigureAwait(false);
Assert.That(accepted, Has.Count.EqualTo(parallelism * perTask));
}
}
}