mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-21 09:06:37 +08:00
9ada62baa4
# 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>
415 lines
16 KiB
C#
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));
|
|
}
|
|
}
|
|
}
|