diff --git a/src/Opc.Ua.PubSub.Udp/Dtls/DtlsDatagramTransport.cs b/src/Opc.Ua.PubSub.Udp/Dtls/DtlsDatagramTransport.cs
index bfee5be9b5..4b452832f3 100644
--- a/src/Opc.Ua.PubSub.Udp/Dtls/DtlsDatagramTransport.cs
+++ b/src/Opc.Ua.PubSub.Udp/Dtls/DtlsDatagramTransport.cs
@@ -42,7 +42,10 @@ namespace Opc.Ua.PubSub.Udp.Dtls
///
/// DTLS wrapper around the UDP datagram transport for Part 14 §7.3.2.4 unicast PubSub.
///
- public sealed class DtlsDatagramTransport : IPubSubTransport, IDtlsDatagramChannel
+ public sealed class DtlsDatagramTransport :
+ IPubSubTransport,
+ IDtlsDatagramChannel,
+ IDtlsAuthenticatedPeerChannel
{
///
/// Initializes a new .
@@ -82,7 +85,8 @@ public DtlsDatagramTransport(
timeProvider,
udpOptions,
diagnostics,
- useConnectedUnicastClient: direction == PubSubTransportDirection.Send);
+ useConnectedUnicastClient: direction == PubSubTransportDirection.Send,
+ trackLastSeenUnicastPeer: false);
}
///
@@ -118,6 +122,7 @@ public event EventHandler? StateChanged
public async ValueTask OpenAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
+ ClearAuthenticatedPeer();
IDtlsContext context = await m_contextFactory.CreateAsync(
m_connection,
Endpoint,
@@ -163,6 +168,7 @@ public async ValueTask CloseAsync(CancellationToken cancellationToken = default)
{
IDtlsContext? context = m_context;
m_context = null;
+ ClearAuthenticatedPeer();
context?.Dispose();
await m_innerTransport.CloseAsync(cancellationToken).ConfigureAwait(false);
}
@@ -186,6 +192,15 @@ public async IAsyncEnumerable ReceiveAsync(
await foreach (PubSubTransportFrame frame in m_innerTransport.ReceiveAsync(cancellationToken)
.ConfigureAwait(false))
{
+ if (!IsAuthenticatedPeer(frame.SourceEndpoint))
+ {
+ // RFC 9147 §5.1: without a negotiated connection ID, a
+ // record from another address is not part of this association.
+ // Filter before record protection so redirected ciphertext
+ // cannot consume the authenticated peer's replay sequence.
+ continue;
+ }
+
ReadOnlyMemory payload;
try
{
@@ -198,7 +213,11 @@ public async IAsyncEnumerable ReceiveAsync(
// silently dropped so a forged datagram cannot tear down the transport.
continue;
}
- yield return new PubSubTransportFrame(payload, frame.Topic, frame.ReceivedAt);
+ yield return new PubSubTransportFrame(
+ payload,
+ frame.Topic,
+ frame.ReceivedAt,
+ frame.SourceEndpoint);
}
}
@@ -207,6 +226,7 @@ public async ValueTask DisposeAsync()
{
IDtlsContext? context = m_context;
m_context = null;
+ ClearAuthenticatedPeer();
context?.Dispose();
await m_innerTransport.DisposeAsync().ConfigureAwait(false);
}
@@ -239,6 +259,54 @@ async ValueTask IDtlsDatagramChannel.ReceiveAsync(CancellationToke
throw new InvalidOperationException("DTLS datagram channel closed while waiting for a handshake datagram.");
}
+ void IDtlsAuthenticatedPeerChannel.SetAuthenticatedPeer(IPEndPoint peer)
+ {
+ SetAuthenticatedPeer(peer);
+ }
+
+ private bool IsAuthenticatedPeer(IPEndPoint? source)
+ {
+ if (source is null)
+ {
+ return false;
+ }
+
+ lock (m_peerLock)
+ {
+ return m_authenticatedPeer is not null &&
+ m_authenticatedPeer.Equals(source);
+ }
+ }
+
+ private void SetAuthenticatedPeer(IPEndPoint peer)
+ {
+ if (peer is null)
+ {
+ throw new ArgumentNullException(nameof(peer));
+ }
+
+ lock (m_peerLock)
+ {
+ if (m_authenticatedPeer is not null &&
+ !m_authenticatedPeer.Equals(peer))
+ {
+ throw new InvalidOperationException(
+ "A connection-ID-less DTLS association cannot change its authenticated peer endpoint.");
+ }
+
+ m_authenticatedPeer ??= new IPEndPoint(peer.Address, peer.Port);
+ m_innerTransport.SetAuthenticatedRemoteEndpoint(m_authenticatedPeer);
+ }
+ }
+
+ private void ClearAuthenticatedPeer()
+ {
+ lock (m_peerLock)
+ {
+ m_authenticatedPeer = null;
+ }
+ }
+
///
/// PubSub connection descriptor backing the DTLS transport.
///
@@ -247,6 +315,8 @@ async ValueTask IDtlsDatagramChannel.ReceiveAsync(CancellationToke
private readonly IDtlsContextFactory m_contextFactory;
private readonly ITelemetryContext m_telemetry;
private readonly TimeProvider m_timeProvider;
+ private readonly Lock m_peerLock = new();
private IDtlsContext? m_context;
+ private IPEndPoint? m_authenticatedPeer;
}
}
diff --git a/src/Opc.Ua.PubSub.Udp/Dtls/DtlsHandshakeContext.cs b/src/Opc.Ua.PubSub.Udp/Dtls/DtlsHandshakeContext.cs
index a4967b724f..d90842af92 100644
--- a/src/Opc.Ua.PubSub.Udp/Dtls/DtlsHandshakeContext.cs
+++ b/src/Opc.Ua.PubSub.Udp/Dtls/DtlsHandshakeContext.cs
@@ -153,6 +153,7 @@ private async ValueTask ConnectAsync(IDtlsDatagramChannel channel, CancellationT
{
using DtlsEcdheKeyExchange ecdhe = new(Profile.KeyExchangeCurve);
var transcript = new DtlsTranscriptHash(GetHashAlgorithm(Profile.CipherSuite));
+ IPEndPoint serverPeer = GetPeerEndpoint(channel);
byte[] sessionId = CreateRandom(32);
byte[] cookie = [];
byte[] clientHelloBody = [];
@@ -169,7 +170,10 @@ private async ValueTask ConnectAsync(IDtlsDatagramChannel channel, CancellationT
clientHelloBody);
await SendFlightAsync(channel, clientHelloFrame, cancellationToken).ConfigureAwait(false);
transcript.Append(clientHelloFrame);
- DtlsHandshakeFrame firstFrame = await ReceiveFrameAsync(channel, cancellationToken)
+ DtlsHandshakeFrame firstFrame = await ReceiveFrameAsync(
+ channel,
+ serverPeer,
+ cancellationToken)
.ConfigureAwait(false);
RequireMessage(firstFrame, DtlsHandshakeType.ServerHello);
DtlsServerHello serverHello = DtlsHandshakeCodec.DecodeServerHello(firstFrame.Fragment);
@@ -194,9 +198,17 @@ private async ValueTask ConnectAsync(IDtlsDatagramChannel channel, CancellationT
byte[] serverHelloHash = transcript.GetHash();
m_keyingContext = new DtlsHandshakeKeyingContext(Profile, sharedSecret, serverHelloHash);
- await ReceiveAndAppendAsync(channel, transcript, DtlsHandshakeType.EncryptedExtensions, cancellationToken)
+ await ReceiveAndAppendAsync(
+ channel,
+ transcript,
+ DtlsHandshakeType.EncryptedExtensions,
+ serverPeer,
+ cancellationToken)
.ConfigureAwait(false);
- DtlsHandshakeFrame certificateFrame = await ReceiveFrameAsync(channel, cancellationToken)
+ DtlsHandshakeFrame certificateFrame = await ReceiveFrameAsync(
+ channel,
+ serverPeer,
+ cancellationToken)
.ConfigureAwait(false);
bool clientCertificateRequested = false;
if (certificateFrame.MessageType == DtlsHandshakeType.CertificateRequest)
@@ -204,7 +216,10 @@ await ReceiveAndAppendAsync(channel, transcript, DtlsHandshakeType.EncryptedExte
DtlsHandshakeCodec.DecodeCertificateRequest(certificateFrame.Fragment);
transcript.Append(ToCompleteFrame(certificateFrame));
clientCertificateRequested = true;
- certificateFrame = await ReceiveFrameAsync(channel, cancellationToken).ConfigureAwait(false);
+ certificateFrame = await ReceiveFrameAsync(
+ channel,
+ serverPeer,
+ cancellationToken).ConfigureAwait(false);
}
RequireMessage(certificateFrame, DtlsHandshakeType.Certificate);
@@ -214,7 +229,10 @@ await ReceiveAndAppendAsync(channel, transcript, DtlsHandshakeType.EncryptedExte
{
await ValidatePeerCertificateAsync(peerChain, cancellationToken).ConfigureAwait(false);
byte[] certificateVerifyTranscriptHash = transcript.GetHash();
- DtlsHandshakeFrame certificateVerifyFrame = await ReceiveFrameAsync(channel, cancellationToken)
+ DtlsHandshakeFrame certificateVerifyFrame = await ReceiveFrameAsync(
+ channel,
+ serverPeer,
+ cancellationToken)
.ConfigureAwait(false);
RequireMessage(certificateVerifyFrame, DtlsHandshakeType.CertificateVerify);
DtlsCertificateAuthenticator.VerifyCertificateVerify(
@@ -226,7 +244,10 @@ await ReceiveAndAppendAsync(channel, transcript, DtlsHandshakeType.EncryptedExte
transcript.Append(ToCompleteFrame(certificateVerifyFrame));
}
byte[] finishedTranscriptHash = transcript.GetHash();
- DtlsHandshakeFrame serverFinishedFrame = await ReceiveFrameAsync(channel, cancellationToken)
+ DtlsHandshakeFrame serverFinishedFrame = await ReceiveFrameAsync(
+ channel,
+ serverPeer,
+ cancellationToken)
.ConfigureAwait(false);
RequireMessage(serverFinishedFrame, DtlsHandshakeType.Finished);
byte[] expectedServerFinished = m_keyingContext.ComputeServerFinished(finishedTranscriptHash);
@@ -256,6 +277,7 @@ await ReceiveAndAppendAsync(channel, transcript, DtlsHandshakeType.EncryptedExte
await SendFlightAsync(channel, clientFinishedFrame, cancellationToken).ConfigureAwait(false);
CryptoUtils.ZeroMemory(clientFinished);
InstallApplicationKeys(isClient: true);
+ SetAuthenticatedPeer(channel, serverPeer);
}
finally
{
@@ -281,7 +303,7 @@ private async ValueTask AcceptAsync(IDtlsDatagramChannel channel, CancellationTo
RequireMessage(clientHelloFrame, DtlsHandshakeType.ClientHello);
clientHello = DtlsHandshakeCodec.DecodeClientHello(clientHelloFrame.Fragment);
ValidateClientHello(clientHello);
- clientSource = source ?? GetCookieEndpoint(channel);
+ clientSource = source ?? GetPeerEndpoint(channel);
using var cookieProtector = new DtlsHelloRetryCookieProtector(cookieKey);
if (m_options.RequireHelloRetryRequestCookie &&
!cookieProtector.ValidateCookie(
@@ -361,11 +383,18 @@ await SendFlightAsync(channel, serverFinishedFrame, cancellationToken, clientSou
m_keyingContext.InstallApplicationSecrets(transcript.GetHash());
if (m_options.RequireClientCertificate)
{
- await ReceiveClientAuthenticationAsync(channel, transcript, cancellationToken)
+ await ReceiveClientAuthenticationAsync(
+ channel,
+ transcript,
+ clientSource,
+ cancellationToken)
.ConfigureAwait(false);
}
- DtlsHandshakeFrame clientFinishedFrame = await ReceiveFrameAsync(channel, cancellationToken)
+ DtlsHandshakeFrame clientFinishedFrame = await ReceiveFrameAsync(
+ channel,
+ clientSource,
+ cancellationToken)
.ConfigureAwait(false);
RequireMessage(clientFinishedFrame, DtlsHandshakeType.Finished);
byte[] expectedClientFinished = m_keyingContext.ComputeClientFinished(transcript.GetHash());
@@ -381,6 +410,7 @@ await ReceiveClientAuthenticationAsync(channel, transcript, cancellationToken)
}
InstallApplicationKeys(isClient: false);
+ SetAuthenticatedPeer(channel, clientSource);
}
finally
{
@@ -441,10 +471,21 @@ private static async ValueTask SendFlightAsync(
private static async ValueTask ReceiveFrameAsync(
IDtlsDatagramChannel channel,
+ IPEndPoint? expectedSource,
CancellationToken cancellationToken)
{
- DtlsDatagram datagram = await channel.ReceiveAsync(cancellationToken).ConfigureAwait(false);
- return DtlsHandshakeCodec.DecodeFrame(datagram.Payload.Span);
+ while (true)
+ {
+ DtlsDatagram datagram = await channel.ReceiveAsync(cancellationToken).ConfigureAwait(false);
+ IPEndPoint? source = datagram.Source ?? channel.RemoteEndpoint;
+ if (expectedSource is not null &&
+ (source is null || !expectedSource.Equals(source)))
+ {
+ continue;
+ }
+
+ return DtlsHandshakeCodec.DecodeFrame(datagram.Payload.Span);
+ }
}
private static async ValueTask<(DtlsHandshakeFrame Frame, IPEndPoint? Source)> ReceiveSourcedFrameAsync(
@@ -483,9 +524,13 @@ private async ValueTask SendClientAuthenticationAsync(
private async ValueTask ReceiveClientAuthenticationAsync(
IDtlsDatagramChannel channel,
DtlsTranscriptHash transcript,
+ IPEndPoint? expectedSource,
CancellationToken cancellationToken)
{
- DtlsHandshakeFrame certificateFrame = await ReceiveFrameAsync(channel, cancellationToken)
+ DtlsHandshakeFrame certificateFrame = await ReceiveFrameAsync(
+ channel,
+ expectedSource,
+ cancellationToken)
.ConfigureAwait(false);
RequireMessage(certificateFrame, DtlsHandshakeType.Certificate);
transcript.Append(ToCompleteFrame(certificateFrame));
@@ -493,7 +538,10 @@ private async ValueTask ReceiveClientAuthenticationAsync(
DtlsCertificateAuthenticator.DecodeCertificate(certificateFrame.Fragment);
await ValidatePeerCertificateAsync(peerChain, cancellationToken).ConfigureAwait(false);
byte[] certificateVerifyTranscriptHash = transcript.GetHash();
- DtlsHandshakeFrame certificateVerifyFrame = await ReceiveFrameAsync(channel, cancellationToken)
+ DtlsHandshakeFrame certificateVerifyFrame = await ReceiveFrameAsync(
+ channel,
+ expectedSource,
+ cancellationToken)
.ConfigureAwait(false);
RequireMessage(certificateVerifyFrame, DtlsHandshakeType.CertificateVerify);
DtlsCertificateAuthenticator.VerifyCertificateVerify(
@@ -545,9 +593,13 @@ private static async ValueTask ReceiveAndAppendAsync(
IDtlsDatagramChannel channel,
DtlsTranscriptHash transcript,
DtlsHandshakeType messageType,
+ IPEndPoint? expectedSource,
CancellationToken cancellationToken)
{
- DtlsHandshakeFrame frame = await ReceiveFrameAsync(channel, cancellationToken).ConfigureAwait(false);
+ DtlsHandshakeFrame frame = await ReceiveFrameAsync(
+ channel,
+ expectedSource,
+ cancellationToken).ConfigureAwait(false);
RequireMessage(frame, messageType);
if (messageType == DtlsHandshakeType.EncryptedExtensions)
{
@@ -705,10 +757,20 @@ private static HashAlgorithmName GetHashAlgorithm(DtlsCipherSuite cipherSuite)
: HashAlgorithmName.SHA256;
}
- private IPEndPoint GetCookieEndpoint(IDtlsDatagramChannel channel)
+ private IPEndPoint GetPeerEndpoint(IDtlsDatagramChannel channel)
{
return channel.RemoteEndpoint ?? new IPEndPoint(m_endpoint.Address, m_endpoint.Port);
}
+
+ private static void SetAuthenticatedPeer(
+ IDtlsDatagramChannel channel,
+ IPEndPoint? peer)
+ {
+ if (peer is not null && channel is IDtlsAuthenticatedPeerChannel peerChannel)
+ {
+ peerChannel.SetAuthenticatedPeer(peer);
+ }
+ }
#endif
#if NET8_0_OR_GREATER
diff --git a/src/Opc.Ua.PubSub.Udp/Dtls/DtlsRecordProtection.cs b/src/Opc.Ua.PubSub.Udp/Dtls/DtlsRecordProtection.cs
index f559737ca3..0d0e863651 100644
--- a/src/Opc.Ua.PubSub.Udp/Dtls/DtlsRecordProtection.cs
+++ b/src/Opc.Ua.PubSub.Udp/Dtls/DtlsRecordProtection.cs
@@ -31,6 +31,7 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Security.Cryptography;
+using System.Threading;
namespace Opc.Ua.PubSub.Udp.Dtls
{
@@ -43,9 +44,26 @@ public sealed class DtlsRecordProtection : IDisposable
/// Initializes a new .
///
public DtlsRecordProtection(DtlsProfile profile, ReadOnlySpan trafficSecret, ushort epoch)
+ : this(profile, trafficSecret, epoch, initialWriteSequenceNumber: 0)
{
+ }
+
+ internal DtlsRecordProtection(
+ DtlsProfile profile,
+ ReadOnlySpan trafficSecret,
+ ushort epoch,
+ ulong initialWriteSequenceNumber)
+ {
+ if (initialWriteSequenceNumber > MaximumRecordSequenceNumber)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(initialWriteSequenceNumber),
+ "The initial DTLS record sequence number exceeds the epoch limit.");
+ }
+
Profile = profile ?? throw new ArgumentNullException(nameof(profile));
Epoch = epoch;
+ m_writeSequenceNumber = initialWriteSequenceNumber;
m_hashAlgorithmName = GetHashAlgorithm(profile.CipherSuite);
m_isAead = IsAead(profile.CipherSuite);
m_tagLength = GetTagLength(profile.CipherSuite);
@@ -90,9 +108,24 @@ public DtlsRecordProtection(DtlsProfile profile, ReadOnlySpan trafficSecre
///
///
public byte[] Seal(ReadOnlySpan plaintext)
+ {
+ lock (m_lock)
+ {
+ return SealCore(plaintext);
+ }
+ }
+
+ private byte[] SealCore(ReadOnlySpan plaintext)
{
ThrowIfDisposed();
- ulong sequenceNumber = m_writeSequenceNumber++;
+ if (m_writeSequenceNumber > MaximumRecordSequenceNumber)
+ {
+ throw new InvalidOperationException(
+ "DTLS record sequence number exhausted for this epoch; install new traffic keys.");
+ }
+
+ ulong sequenceNumber = m_writeSequenceNumber;
+ m_writeSequenceNumber++;
int innerPlaintextLength = plaintext.Length + 1;
int protectedLength = innerPlaintextLength + m_tagLength;
byte[] record = new byte[HeaderLength + protectedLength];
@@ -169,6 +202,14 @@ public byte[] Open(ReadOnlySpan record)
///
///
public bool TryOpen(ReadOnlySpan record, out byte[]? applicationData)
+ {
+ lock (m_lock)
+ {
+ return TryOpenCore(record, out applicationData);
+ }
+ }
+
+ private bool TryOpenCore(ReadOnlySpan record, out byte[]? applicationData)
{
ThrowIfDisposed();
applicationData = null;
@@ -288,19 +329,22 @@ public bool TryOpen(ReadOnlySpan record, out byte[]? applicationData)
///
public void Dispose()
{
- if (m_disposed)
+ lock (m_lock)
{
- return;
- }
+ if (m_disposed)
+ {
+ return;
+ }
- CryptoUtils.ZeroMemory(m_key);
- CryptoUtils.ZeroMemory(m_iv);
- CryptoUtils.ZeroMemory(m_snKey);
+ CryptoUtils.ZeroMemory(m_key);
+ CryptoUtils.ZeroMemory(m_iv);
+ CryptoUtils.ZeroMemory(m_snKey);
#if NET8_0_OR_GREATER
- m_aesGcm?.Dispose();
- m_chacha20Poly1305?.Dispose();
+ m_aesGcm?.Dispose();
+ m_chacha20Poly1305?.Dispose();
#endif
- m_disposed = true;
+ m_disposed = true;
+ }
}
private static void WriteHeader(Span destination, ushort epoch, ulong sequenceNumber, int protectedLength)
@@ -629,7 +673,9 @@ private void ThrowIfDisposed()
private const byte ApplicationDataContentType = 0x17;
private const int NonceLength = 12;
private const int SequenceNumberSampleLength = 16;
+ internal const ulong MaximumRecordSequenceNumber = (1UL << 48) - 1;
+ private readonly Lock m_lock = new();
private readonly HashAlgorithmName m_hashAlgorithmName;
private readonly byte[] m_key;
private readonly byte[] m_iv;
diff --git a/src/Opc.Ua.PubSub.Udp/Dtls/IDtlsDatagramChannel.cs b/src/Opc.Ua.PubSub.Udp/Dtls/IDtlsDatagramChannel.cs
index 32ebf6f04b..8ccc7f1a28 100644
--- a/src/Opc.Ua.PubSub.Udp/Dtls/IDtlsDatagramChannel.cs
+++ b/src/Opc.Ua.PubSub.Udp/Dtls/IDtlsDatagramChannel.cs
@@ -84,4 +84,19 @@ ValueTask SendAsync(
///
ValueTask ReceiveAsync(CancellationToken cancellationToken = default);
}
+
+ ///
+ /// Optional channel capability used by a DTLS context to pin the
+ /// connection-ID-less association to the peer endpoint authenticated
+ /// by the completed handshake.
+ ///
+ public interface IDtlsAuthenticatedPeerChannel
+ {
+ ///
+ /// Pins the authenticated peer endpoint. Rebinding requires a new
+ /// handshake when DTLS connection IDs are not negotiated.
+ ///
+ /// Authenticated remote endpoint.
+ void SetAuthenticatedPeer(IPEndPoint peer);
+ }
}
diff --git a/src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs b/src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs
index 5b79a66c95..521e7c6326 100644
--- a/src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs
+++ b/src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs
@@ -92,6 +92,7 @@ public sealed class UdpDatagramTransport : IPubSubTransport, IPubSubDiscoveryAnn
private IPEndPoint? m_sendDestination;
private bool m_socketIsConnected;
private readonly bool m_useConnectedUnicastClient;
+ private readonly bool m_trackLastSeenUnicastPeer;
///
/// Initializes a new .
@@ -135,7 +136,17 @@ public UdpDatagramTransport(
TimeProvider timeProvider,
UdpTransportOptions options,
IPubSubDiagnostics? diagnostics = null)
- : this(connection, endpoint, direction, networkInterface, telemetry, timeProvider, options, diagnostics, false)
+ : this(
+ connection,
+ endpoint,
+ direction,
+ networkInterface,
+ telemetry,
+ timeProvider,
+ options,
+ diagnostics,
+ useConnectedUnicastClient: false,
+ trackLastSeenUnicastPeer: true)
{
}
@@ -148,7 +159,8 @@ internal UdpDatagramTransport(
TimeProvider timeProvider,
UdpTransportOptions options,
IPubSubDiagnostics? diagnostics,
- bool useConnectedUnicastClient)
+ bool useConnectedUnicastClient,
+ bool trackLastSeenUnicastPeer)
{
if (connection is null)
{
@@ -180,6 +192,7 @@ internal UdpDatagramTransport(
m_options = options;
m_diagnostics = diagnostics;
m_useConnectedUnicastClient = useConnectedUnicastClient;
+ m_trackLastSeenUnicastPeer = trackLastSeenUnicastPeer;
m_logger = telemetry.CreateLogger();
m_repeater = new UdpMessageRepeater(
options.MessageRepeatCount,
@@ -224,6 +237,26 @@ internal IPEndPoint? RemoteEndpoint
}
}
+ internal void SetAuthenticatedRemoteEndpoint(IPEndPoint remoteEndpoint)
+ {
+ if (remoteEndpoint is null)
+ {
+ throw new ArgumentNullException(nameof(remoteEndpoint));
+ }
+
+ lock (m_sync)
+ {
+ if (m_socketIsConnected &&
+ m_sendDestination is not null &&
+ !m_sendDestination.Equals(remoteEndpoint))
+ {
+ return;
+ }
+
+ m_sendDestination = new IPEndPoint(remoteEndpoint.Address, remoteEndpoint.Port);
+ }
+ }
+
///
/// DiscoveryAnnounceRate value (milliseconds) honoured from the
/// DatagramConnectionTransport2DataType per
@@ -231,9 +264,6 @@ internal IPEndPoint? RemoteEndpoint
/// Part 14 §6.4.1.2.7. Zero means disabled.
///
public uint DiscoveryAnnounceRate => m_v2Settings.DiscoveryAnnounceRate;
- // TODO(B15): add DTLS 1.3 handshake/record protection for opc.dtls://
- // unicast per Part 14 §7.3.2.4; the parser rejects DTLS URLs until an
- // injectable provider can guarantee payload protection.
///
/// Standard IPv4 discovery multicast destination from Part 14 §7.3.2.1.
@@ -696,6 +726,7 @@ private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
receivedAt: new DateTimeUtc(m_timeProvider.GetUtcNow().UtcDateTime),
sourceEndpoint: sourceEndpoint);
if (Endpoint.AddressType == UdpAddressType.Unicast &&
+ m_trackLastSeenUnicastPeer &&
sourceEndpoint is not null)
{
lock (m_sync)
diff --git a/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs b/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs
index 78f2893f48..ff270dcccc 100644
--- a/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs
+++ b/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs
@@ -87,7 +87,6 @@ public sealed class PubSubConnection : IPubSubConnection, IAsyncDisposable
private int m_chunkSequenceNumber;
private int m_discoverySequenceNumber;
private int m_actionRequestId;
- private bool m_allowUnsecuredActions;
private readonly ILogger m_logger;
private readonly Lock m_gate = new();
private readonly Lock m_receivedSinksGate = new();
@@ -793,12 +792,12 @@ public void RegisterActionHandler(
}
var responder = new ActionResponder(
handler,
+ allowUnsecured,
responseAddressPolicy ?? PubSubResponseAddressPolicy.Default);
ushort actionTargetId = ResolveActionTargetId(target);
var key = new ActionHandlerKey(target.DataSetWriterId, actionTargetId, target.ActionName);
lock (m_gate)
{
- m_allowUnsecuredActions |= allowUnsecured;
m_actionHandlers[key] = responder;
m_actionHandlers[new ActionHandlerKey(
target.DataSetWriterId,
@@ -959,8 +958,8 @@ in transport.ReceiveAsync(cancellationToken).ConfigureAwait(false))
out prefixLength,
out securityEnabled,
out bool reassembledChunk,
- out _,
- out _) ||
+ out framePublisherId,
+ out frameWriterGroupId) ||
reassembledChunk)
{
// Fail-soft: a reassembled payload that is not a
@@ -991,6 +990,7 @@ in transport.ReceiveAsync(cancellationToken).ConfigureAwait(false))
}
ReadOnlyMemory? unwrapped = await TryUnwrapInboundAsync(
framePayload, prefixLength,
+ framePublisherId, frameWriterGroupId,
m_requiredSecurityMode, cancellationToken)
.ConfigureAwait(false);
if (unwrapped is null)
@@ -1003,6 +1003,7 @@ in transport.ReceiveAsync(cancellationToken).ConfigureAwait(false))
{
ReadOnlyMemory? unwrapped = await TryUnwrapInboundAsync(
framePayload, prefixLength,
+ framePublisherId, frameWriterGroupId,
MessageSecurityMode.None, cancellationToken)
.ConfigureAwait(false);
if (unwrapped is null)
@@ -1474,25 +1475,30 @@ private async ValueTask TryRespondToActionRequestAsync(
UadpActionRequestMessage request,
CancellationToken cancellationToken)
{
- // Fail-closed (SA-ACT-01): a UADP Action request reaches this point
- // only after the inbound security gate. Serve it only when the
- // connection requires (and therefore verified) message security, or
- // when unsecured Action serving was explicitly opted in.
- if (!RequiresInboundSecurity && !m_allowUnsecuredActions)
- {
- RecordSecurityFailure(
- StatusCodes.BadSecurityModeRejected,
- "Refusing to serve a PubSub Action request on a connection that " +
- "does not require message security. Configure Sign/SignAndEncrypt " +
- "or explicitly allow unsecured Action responders.");
- return;
- }
ActionResponder? responder = ResolveActionHandler(
request.DataSetWriterId,
request.ActionTargetId,
actionName: string.Empty);
if (responder is null)
{
+ if (!RequiresInboundSecurity)
+ {
+ RecordSecurityFailure(
+ StatusCodes.BadSecurityModeRejected,
+ "No matching PubSub Action responder permits this unsecured request.");
+ }
+ return;
+ }
+ // Fail-closed (SA-ACT-01): an unsecured connection may dispatch only
+ // to the specific responder that explicitly opted into unsecured
+ // requests. One permissive responder must never weaken its peers.
+ if (!RequiresInboundSecurity && !responder.AllowUnsecured)
+ {
+ RecordSecurityFailure(
+ StatusCodes.BadSecurityModeRejected,
+ "Refusing to serve a PubSub Action request on a connection that " +
+ "does not require message security. Configure Sign/SignAndEncrypt " +
+ "or explicitly allow this Action responder to serve unsecured requests.");
return;
}
// Validate the requestor-supplied response topic before the handler
@@ -1544,25 +1550,26 @@ private async ValueTask TryRespondToJsonActionRequestAsync(
JsonActionRequestMessage request,
CancellationToken cancellationToken)
{
- // Fail-closed (SA-ACT-01): JSON Action frames are not protected by the
- // UADP message-security gate, so there is no message-level proof of the
- // requestor's identity. Serve them only when unsecured Action serving
- // was explicitly opted in (transport TLS is then the trust boundary).
- if (!m_allowUnsecuredActions)
- {
- RecordSecurityFailure(
- StatusCodes.BadSecurityModeRejected,
- "Refusing to serve a JSON PubSub Action request: JSON Action frames " +
- "carry no UADP message security. Explicitly allow unsecured Action " +
- "responders (and secure the transport) to enable this.");
- return;
- }
ActionResponder? responder = ResolveActionHandler(
request.DataSetWriterId,
request.ActionTargetId,
actionName: string.Empty);
if (responder is null)
{
+ RecordSecurityFailure(
+ StatusCodes.BadSecurityModeRejected,
+ "No matching JSON PubSub Action responder permits this unsecured request.");
+ return;
+ }
+ // JSON Action frames carry no UADP message security. Apply the
+ // unsecured opt-in of the resolved responder, not a connection-wide
+ // flag that could be enabled by an unrelated responder.
+ if (!responder.AllowUnsecured)
+ {
+ RecordSecurityFailure(
+ StatusCodes.BadSecurityModeRejected,
+ "Refusing to serve a JSON PubSub Action request: this responder " +
+ "does not allow unsecured requests.");
return;
}
// Validate the requestor-supplied response topic before the handler
@@ -2539,6 +2546,8 @@ private static string TransportProfileFamily(string profile)
private async ValueTask?> TryUnwrapInboundAsync(
ReadOnlyMemory frame,
int prefixLength,
+ PublisherId publisherId,
+ ushort writerGroupId,
MessageSecurityMode requiredMode,
CancellationToken cancellationToken)
{
@@ -2548,7 +2557,13 @@ private static string TransportProfileFamily(string profile)
ReadOnlyMemory securityAndPayload = frame[prefixLength..];
UadpSecurityWrapper.UnwrapResult result = await m_securityWrapper!
- .TryUnwrapAsync(prefix, securityAndPayload, cancellationToken)
+ .TryUnwrapAsync(
+ prefix,
+ securityAndPayload,
+ publisherId,
+ writerGroupId,
+ requiredMode,
+ cancellationToken)
.ConfigureAwait(false);
if (!result.IsSuccess || result.InnerPayload is null)
{
@@ -2556,16 +2571,6 @@ private static string TransportProfileFamily(string profile)
return null;
}
- if (!SatisfiesRequiredSecurity(requiredMode, result.Header))
- {
- RecordSecurityFailure(
- StatusCodes.BadSecurityModeRejected,
- "Inbound frame security level is lower than the reader's " +
- "configured SecurityMode.");
- m_logger.DroppingInboundFrameSecurityBelowRequired(Name, requiredMode);
- return null;
- }
-
ReadOnlyMemory cleartext = result.InnerPayload.Value;
int totalLength = prefix.Length + cleartext.Length;
byte[] combined = new byte[totalLength];
@@ -2585,30 +2590,6 @@ private static string TransportProfileFamily(string profile)
}
}
- private static bool SatisfiesRequiredSecurity(
- MessageSecurityMode requiredMode,
- UadpSecurityHeader? header)
- {
- if (requiredMode is not (MessageSecurityMode.Sign
- or MessageSecurityMode.SignAndEncrypt))
- {
- return true;
- }
- if (header is null)
- {
- return false;
- }
- var flags = (UadpSecurityFlagsEncodingMask)header.Value.SecurityFlags;
- bool signed = (flags & UadpSecurityFlagsEncodingMask.NetworkMessageSigned) != 0;
- bool encrypted =
- (flags & UadpSecurityFlagsEncodingMask.NetworkMessageEncrypted) != 0;
- if (requiredMode == MessageSecurityMode.SignAndEncrypt)
- {
- return signed && encrypted;
- }
- return signed;
- }
-
private void RecordSecurityFailure(StatusCode status, string message)
{
PubSubDiagnosticsCounterKind kind;
@@ -2835,6 +2816,7 @@ private static string ToCorrelationKey(ByteString value)
private sealed record ActionResponder(
IPubSubActionHandler Handler,
+ bool AllowUnsecured,
PubSubResponseAddressPolicy ResponseAddressPolicy);
private readonly struct ActionHandlerKey : IEquatable
@@ -3062,13 +3044,6 @@ public static partial void DroppingOutboundMessage(
Message = "UADP security wrap failed; dropping message.")]
public static partial void UadpSecurityWrapFailed(this ILogger logger, Exception exception);
- [LoggerMessage(EventId = PubSubEventIds.PubSubConnection + 19, Level = LogLevel.Warning,
- Message = "Dropping inbound frame on connection '{Connection}': security level below required {Mode}.")]
- public static partial void DroppingInboundFrameSecurityBelowRequired(
- this ILogger logger,
- string connection,
- MessageSecurityMode mode);
-
[LoggerMessage(EventId = PubSubEventIds.PubSubConnection + 20, Level = LogLevel.Error,
Message = "UADP unwrap threw on inbound frame.")]
public static partial void UadpUnwrapThrewOnInboundFrame(this ILogger logger, Exception exception);
diff --git a/src/Opc.Ua.PubSub/Security/ISecurityTokenWindow.cs b/src/Opc.Ua.PubSub/Security/ISecurityTokenWindow.cs
index 455b2206f2..5286cfa172 100644
--- a/src/Opc.Ua.PubSub/Security/ISecurityTokenWindow.cs
+++ b/src/Opc.Ua.PubSub/Security/ISecurityTokenWindow.cs
@@ -28,16 +28,15 @@
* ======================================================================*/
using System;
+using Opc.Ua.PubSub.Encoding;
namespace Opc.Ua.PubSub.Security
{
///
- /// Per-writer-group reception window enforcing replay protection
+ /// Legacy token-scoped reception window enforcing replay protection
/// over the (TokenId, SequenceNumber, Nonce)
- /// triple. Implementations track the last accepted sequence
- /// number per token plus a sliding bitmap of recently seen
- /// sequence numbers, and reject duplicate / out-of-window /
- /// nonce-reuse frames.
+ /// triple. The built-in receive path adds authenticated PublisherId
+ /// and WriterGroupId scoping without changing this public contract.
///
///
/// Implements the receiver-side replay protection requirement
@@ -54,6 +53,10 @@ public interface ISecurityTokenWindow
/// edge, or the nonce has already been used inside the
/// current key's lifetime.
///
+ ///
+ /// Implementations may conservatively reject a fresh nonce when using
+ /// bounded probabilistic nonce tracking.
+ ///
/// SecurityHeader TokenId.
/// SecurityHeader SequenceNumber.
/// SecurityHeader Nonce bytes.
@@ -70,4 +73,14 @@ public interface ISecurityTokenWindow
///
void Reset();
}
+
+ internal interface IScopedSecurityTokenWindow
+ {
+ bool TryAccept(
+ PublisherId publisherId,
+ ushort writerGroupId,
+ uint tokenId,
+ ulong sequenceNumber,
+ ReadOnlySpan nonce);
+ }
}
diff --git a/src/Opc.Ua.PubSub/Security/SecurityTokenWindow.cs b/src/Opc.Ua.PubSub/Security/SecurityTokenWindow.cs
index e7eb49ff8a..118be8a779 100644
--- a/src/Opc.Ua.PubSub/Security/SecurityTokenWindow.cs
+++ b/src/Opc.Ua.PubSub/Security/SecurityTokenWindow.cs
@@ -30,6 +30,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
+using Opc.Ua.PubSub.Encoding;
namespace Opc.Ua.PubSub.Security
{
@@ -49,45 +50,55 @@ namespace Opc.Ua.PubSub.Security
/// Part 14 §7.2.4.4.3.1 PubSub security policies.
///
///
- /// State per registered TokenId: the highest accepted
- /// sequence number and a sliding bitmap of the most recent
- /// sequence numbers (IPsec-style
- /// anti-replay). A sequence number that falls below the lower
- /// edge of the window — i.e. more than
- /// behind the highest accepted value — is permanently rejected as
- /// "too old", so a captured message can never be replayed once the
- /// window has advanced past it (no eviction-replay). Duplicates
- /// inside the window are rejected via the bitmap.
+ /// The stack receive path keeps independent state per authenticated
+ /// (PublisherId, WriterGroupId, TokenId) scope. The legacy
+ /// contract retains a
+ /// token-only scope for compatibility. Each scope tracks the highest
+ /// accepted sequence number and a sliding bitmap of the most recent
+ /// sequence numbers (IPsec-style anti-replay).
+ /// A sequence number that falls below the lower edge of the window is
+ /// permanently rejected as "too old", so a captured message can never
+ /// be replayed once the window has advanced past it (no eviction-replay).
///
///
- /// In addition the window retains the full bytes of the
- /// most recently seen nonces (bounded by )
- /// and rejects any exact nonce reuse. Because every legitimate
- /// message carries a strictly increasing sequence number folded
- /// into its nonce, an evicted nonce always maps to a sequence
- /// below the window's lower edge and is therefore still rejected
- /// by the monotonic check.
+ /// In addition each token owns a fixed-size 1 MiB Bloom filter that rejects
+ /// nonce reuse globally across all publisher and writer-group scopes
+ /// without retaining an unbounded set of nonce values. Filter bits are
+ /// never cleared while the token is active, so an exact nonce reuse
+ /// cannot become acceptable. As with any Bloom filter, saturation can
+ /// cause a fresh nonce to be rejected as a false positive; retiring the
+ /// token or resetting the window clears the filter.
///
///
- public sealed class SecurityTokenWindow : ISecurityTokenWindow
+ public sealed class SecurityTokenWindow : ISecurityTokenWindow, IScopedSecurityTokenWindow
{
private readonly Lock m_lock = new();
private readonly Dictionary m_states = [];
+ private readonly Dictionary m_scopedStates = [];
+ private readonly Dictionary m_nonceFilters = [];
///
/// Initializes a new .
///
///
/// Maximum number of accepted sequence numbers retained per
- /// token before eviction. Must be positive.
+ /// publisher/writer-group/token scope. Must be positive.
///
///
/// Time source. Currently unused — accepted for symmetry with
- /// other PubSub services and to allow future TTL eviction.
+ /// other PubSub services and future policy enforcement.
///
public SecurityTokenWindow(
int historySize = 1024,
TimeProvider? timeProvider = null)
+ : this(historySize, timeProvider, DefaultNonceFilterSizeInBytes)
+ {
+ }
+
+ internal SecurityTokenWindow(
+ int historySize,
+ TimeProvider? timeProvider,
+ int nonceFilterSizeInBytes)
{
if (historySize <= 0)
{
@@ -95,12 +106,19 @@ public SecurityTokenWindow(
nameof(historySize),
"History size must be positive.");
}
+ if (nonceFilterSizeInBytes <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(nonceFilterSizeInBytes),
+ "Nonce filter size must be positive.");
+ }
HistorySize = historySize;
TimeProvider = timeProvider ?? TimeProvider.System;
+ NonceFilterSizeInBytes = nonceFilterSizeInBytes;
}
///
- /// Configured per-token history size.
+ /// Configured per-scope sequence history size.
///
public int HistorySize { get; }
@@ -109,6 +127,8 @@ public SecurityTokenWindow(
///
public TimeProvider TimeProvider { get; }
+ internal int NonceFilterSizeInBytes { get; }
+
///
/// Snapshot of the currently registered tokens.
///
@@ -135,6 +155,7 @@ public void RegisterToken(uint tokenId)
if (!m_states.ContainsKey(tokenId))
{
m_states.Add(tokenId, new TokenState(HistorySize));
+ m_nonceFilters.Add(tokenId, new NonceReplayFilter(NonceFilterSizeInBytes));
}
}
}
@@ -149,6 +170,23 @@ public void RetireToken(uint tokenId)
lock (m_lock)
{
m_states.Remove(tokenId);
+ m_nonceFilters.Remove(tokenId);
+ List? retiredScopes = null;
+ foreach (ScopedTokenKey key in m_scopedStates.Keys)
+ {
+ if (key.TokenId == tokenId)
+ {
+ retiredScopes ??= [];
+ retiredScopes.Add(key);
+ }
+ }
+ if (retiredScopes is not null)
+ {
+ foreach (ScopedTokenKey key in retiredScopes)
+ {
+ m_scopedStates.Remove(key);
+ }
+ }
}
}
@@ -158,55 +196,158 @@ public bool TryAccept(
ulong sequenceNumber,
ReadOnlySpan nonce)
{
- // Copy the nonce before taking the lock so the reuse set
- // can retain the full bytes (no truncation) for an exact
- // comparison on later frames.
- byte[]? nonceKey = nonce.Length == 0 ? null : nonce.ToArray();
-
lock (m_lock)
{
- if (!m_states.TryGetValue(tokenId, out TokenState? state))
+ if (!m_states.TryGetValue(tokenId, out TokenState? state) ||
+ !m_nonceFilters.TryGetValue(tokenId, out NonceReplayFilter? nonceFilter))
{
return false;
}
- // Reject exact nonce reuse before mutating any state.
- if (nonceKey != null && state.SeenNonces.Contains(nonceKey))
+ return TryAcceptState(
+ state,
+ nonceFilter,
+ sequenceNumber,
+ nonce);
+ }
+ }
+
+ bool IScopedSecurityTokenWindow.TryAccept(
+ PublisherId publisherId,
+ ushort writerGroupId,
+ uint tokenId,
+ ulong sequenceNumber,
+ ReadOnlySpan nonce)
+ {
+ lock (m_lock)
+ {
+ if (!m_states.ContainsKey(tokenId) ||
+ !m_nonceFilters.TryGetValue(tokenId, out NonceReplayFilter? nonceFilter))
{
return false;
}
- // Reject too-old / duplicate sequence numbers without
- // mutating the window when the nonce check passed.
- if (!state.WouldAcceptSequence(sequenceNumber, HistorySize))
+ var key = new ScopedTokenKey(publisherId, writerGroupId, tokenId);
+ if (!m_scopedStates.TryGetValue(key, out TokenState? state))
{
- return false;
+ state = new TokenState(HistorySize);
+ m_scopedStates.Add(key, state);
}
+ return TryAcceptState(
+ state,
+ nonceFilter,
+ sequenceNumber,
+ nonce);
+ }
+ }
- state.CommitSequence(sequenceNumber, HistorySize);
+ ///
+ public void Reset()
+ {
+ lock (m_lock)
+ {
+ m_states.Clear();
+ m_scopedStates.Clear();
+ m_nonceFilters.Clear();
+ }
+ }
+
+ private bool TryAcceptState(
+ TokenState state,
+ NonceReplayFilter nonceFilter,
+ ulong sequenceNumber,
+ ReadOnlySpan nonce)
+ {
+ if (!nonce.IsEmpty && nonceFilter.MightContain(nonce))
+ {
+ return false;
+ }
+
+ if (!state.WouldAcceptSequence(sequenceNumber, HistorySize))
+ {
+ return false;
+ }
+
+ state.CommitSequence(sequenceNumber, HistorySize);
+
+ if (!nonce.IsEmpty)
+ {
+ nonceFilter.Add(nonce);
+ }
+
+ return true;
+ }
- if (nonceKey != null)
+ private readonly record struct ScopedTokenKey(
+ PublisherId PublisherId,
+ ushort WriterGroupId,
+ uint TokenId);
+
+ private sealed class NonceReplayFilter
+ {
+ public NonceReplayFilter(int sizeInBytes)
+ {
+ m_bits = new byte[sizeInBytes];
+ m_bitCount = checked((ulong)sizeInBytes * 8);
+ }
+
+ public bool MightContain(ReadOnlySpan nonce)
+ {
+ (ulong firstHash, ulong secondHash) = ComputeHashes(nonce);
+ for (int ii = 0; ii < NonceHashCount; ii++)
{
- if (state.SeenNonces.Count >= HistorySize)
+ ulong bitIndex = unchecked(firstHash + ((ulong)ii * secondHash)) % m_bitCount;
+ int bitMask = 1 << (int)(bitIndex & 7);
+ if ((m_bits[(int)(bitIndex >> 3)] & bitMask) == 0)
{
- byte[] evicted = state.NonceOrder.Dequeue();
- state.SeenNonces.Remove(evicted);
+ return false;
}
- state.SeenNonces.Add(nonceKey);
- state.NonceOrder.Enqueue(nonceKey);
}
return true;
}
- }
- ///
- public void Reset()
- {
- lock (m_lock)
+ public void Add(ReadOnlySpan nonce)
{
- m_states.Clear();
+ (ulong firstHash, ulong secondHash) = ComputeHashes(nonce);
+ for (int ii = 0; ii < NonceHashCount; ii++)
+ {
+ ulong bitIndex = unchecked(firstHash + ((ulong)ii * secondHash)) % m_bitCount;
+ m_bits[(int)(bitIndex >> 3)] |= (byte)(1 << (int)(bitIndex & 7));
+ }
+ }
+
+ private static (ulong FirstHash, ulong SecondHash) ComputeHashes(
+ ReadOnlySpan nonce)
+ {
+ ulong firstHash = ComputeHash(nonce, 14695981039346656037UL);
+ ulong secondHash = ComputeHash(nonce, 7809847782465536322UL) | 1UL;
+ return (firstHash, secondHash);
+ }
+
+ private static ulong ComputeHash(ReadOnlySpan nonce, ulong seed)
+ {
+ unchecked
+ {
+ const ulong prime = 1099511628211UL;
+ ulong hash = seed ^ (ulong)nonce.Length;
+ for (int ii = 0; ii < nonce.Length; ii++)
+ {
+ hash = (hash ^ nonce[ii]) * prime;
+ }
+
+ hash ^= hash >> 33;
+ hash *= 0xff51afd7ed558ccdUL;
+ hash ^= hash >> 33;
+ hash *= 0xc4ceb9fe1a85ec53UL;
+ return hash ^ (hash >> 33);
+ }
}
+
+ private const int NonceHashCount = 7;
+
+ private readonly byte[] m_bits;
+ private readonly ulong m_bitCount;
}
private sealed class TokenState
@@ -220,10 +361,6 @@ public TokenState(int historyBits)
m_window = new ulong[(historyBits + 63) / 64];
}
- public HashSet SeenNonces { get; } = new(NonceComparer.Instance);
-
- public Queue NonceOrder { get; } = new();
-
///
/// Returns whether would
/// be accepted without mutating any state.
@@ -308,37 +445,7 @@ private void ShiftUp(ulong delta, int historyBits)
}
}
- private sealed class NonceComparer : IEqualityComparer
- {
- public static readonly NonceComparer Instance = new();
+ private const int DefaultNonceFilterSizeInBytes = 1024 * 1024;
- public bool Equals(byte[]? x, byte[]? y)
- {
- if (ReferenceEquals(x, y))
- {
- return true;
- }
- if (x is null || y is null)
- {
- return false;
- }
- return x.AsSpan().SequenceEqual(y);
- }
-
- public int GetHashCode(byte[] obj)
- {
- unchecked
- {
- const ulong offsetBasis = 14695981039346656037UL;
- const ulong prime = 1099511628211UL;
- ulong hash = offsetBasis;
- for (int i = 0; i < obj.Length; i++)
- {
- hash = (hash ^ obj[i]) * prime;
- }
- return (int)(hash ^ (hash >> 32));
- }
- }
- }
}
}
diff --git a/src/Opc.Ua.PubSub/Security/UadpSecurityWrapper.cs b/src/Opc.Ua.PubSub/Security/UadpSecurityWrapper.cs
index 1d986530a2..ea9dd2d831 100644
--- a/src/Opc.Ua.PubSub/Security/UadpSecurityWrapper.cs
+++ b/src/Opc.Ua.PubSub/Security/UadpSecurityWrapper.cs
@@ -29,9 +29,11 @@
using System;
using System.Buffers;
+using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
+using Opc.Ua.PubSub.Encoding;
namespace Opc.Ua.PubSub.Security
{
@@ -235,10 +237,41 @@ public async ValueTask> WrapAsync(
/// payload on success; otherwise an
/// describing why.
///
- public async ValueTask TryUnwrapAsync(
+ public ValueTask TryUnwrapAsync(
ReadOnlyMemory outerPrefix,
ReadOnlyMemory securityAndPayload,
CancellationToken cancellationToken = default)
+ {
+ return TryUnwrapCoreAsync(
+ outerPrefix,
+ securityAndPayload,
+ replayIdentity: null,
+ MessageSecurityMode.None,
+ cancellationToken);
+ }
+
+ internal ValueTask TryUnwrapAsync(
+ ReadOnlyMemory outerPrefix,
+ ReadOnlyMemory securityAndPayload,
+ PublisherId publisherId,
+ ushort writerGroupId,
+ MessageSecurityMode requiredMode,
+ CancellationToken cancellationToken = default)
+ {
+ return TryUnwrapCoreAsync(
+ outerPrefix,
+ securityAndPayload,
+ new ReplayIdentity(publisherId, writerGroupId),
+ requiredMode,
+ cancellationToken);
+ }
+
+ private async ValueTask TryUnwrapCoreAsync(
+ ReadOnlyMemory outerPrefix,
+ ReadOnlyMemory securityAndPayload,
+ ReplayIdentity? replayIdentity,
+ MessageSecurityMode requiredMode,
+ CancellationToken cancellationToken)
{
if (!UadpSecurityHeader.TryRead(
securityAndPayload.Span,
@@ -309,14 +342,44 @@ public async ValueTask TryUnwrapAsync(
}
}
- // The MessageNonce embeds a monotonic per-key
- // SequenceNumber (Part 14 Table 156: RandomBytes ||
- // SequenceNumber). The nonce is part of the signed
- // SecurityHeader, so the sequence number is
- // authenticated and available before decryption.
- // Extract it and drive the monotonic replay window with
- // it, rejecting duplicates, too-old sequences and exact
- // nonce reuse.
+ if (!SatisfiesRequiredSecurity(requiredMode, flagsMask))
+ {
+ return UnwrapResult.Failure(
+ StatusCodes.BadSecurityModeRejected,
+ "Inbound frame security level is lower than the configured SecurityMode.");
+ }
+
+ byte[] plaintext = new byte[payloadAndFooterLength];
+ if (encrypted && Policy.EncryptingKeyLength > 0)
+ {
+ try
+ {
+ Policy.Decrypt(
+ securityAndPayload.Span.Slice(headerLength, payloadAndFooterLength),
+ key.EncryptingKey.Span,
+ header.MessageNonce.Span,
+ plaintext);
+ }
+ catch (CryptographicException)
+ {
+ Array.Clear(plaintext, 0, plaintext.Length);
+ return UnwrapResult.Failure(
+ StatusCodes.BadSecurityChecksFailed,
+ "Payload decryption or authentication failed");
+ }
+ }
+ else
+ {
+ securityAndPayload
+ .Span
+ .Slice(headerLength, payloadAndFooterLength)
+ .CopyTo(plaintext);
+ }
+
+ // Commit replay state only after every cryptographic operation
+ // has succeeded. This prevents a forged record that fails during
+ // decryption or authentication from poisoning the sequence window
+ // or nonce filter.
ulong sequenceNumber = 0;
ReadOnlySpan nonceSpan = header.MessageNonce.Span;
if (nonceSpan.Length == AesCtrNonceLayout.NonceLength)
@@ -324,11 +387,50 @@ public async ValueTask TryUnwrapAsync(
(_, sequenceNumber) = AesCtrNonceLayout.Parse(nonceSpan);
}
- if (!m_tokenWindow.TryAccept(
- header.SecurityTokenId,
- sequenceNumber,
- nonceSpan))
+ bool replayAccepted;
+ if (replayIdentity is ReplayIdentity identity)
{
+ if (signed &&
+ signatureLength > 0 &&
+ m_tokenWindow is IScopedSecurityTokenWindow scopedWindow)
+ {
+ replayAccepted = scopedWindow.TryAccept(
+ identity.PublisherId,
+ identity.WriterGroupId,
+ header.SecurityTokenId,
+ sequenceNumber,
+ nonceSpan);
+ }
+ else if (signed && signatureLength > 0)
+ {
+ replayAccepted = m_tokenWindow.TryAccept(
+ header.SecurityTokenId,
+ sequenceNumber,
+ nonceSpan);
+ }
+ else
+ {
+ // The PublisherId, WriterGroupId and nonce are not
+ // authenticated on an unsigned frame. Do not let it
+ // mutate replay state for authenticated traffic.
+ replayAccepted = true;
+ }
+ }
+ else if (signed && signatureLength > 0)
+ {
+ replayAccepted = m_tokenWindow.TryAccept(
+ header.SecurityTokenId,
+ sequenceNumber,
+ nonceSpan);
+ }
+ else
+ {
+ replayAccepted = true;
+ }
+
+ if (!replayAccepted)
+ {
+ Array.Clear(plaintext, 0, plaintext.Length);
m_logger.RejectedReplayOrNonceReuse(header.SecurityTokenId, sequenceNumber);
EmitSecurityEvent(new PubSubSecurityEvent(
PubSubSecurityEventKind.ReplayRejected,
@@ -340,23 +442,6 @@ public async ValueTask TryUnwrapAsync(
"Replay or nonce reuse detected");
}
- byte[] plaintext = new byte[payloadAndFooterLength];
- if (encrypted && Policy.EncryptingKeyLength > 0)
- {
- Policy.Decrypt(
- securityAndPayload.Span.Slice(headerLength, payloadAndFooterLength),
- key.EncryptingKey.Span,
- header.MessageNonce.Span,
- plaintext);
- }
- else
- {
- securityAndPayload
- .Span
- .Slice(headerLength, payloadAndFooterLength)
- .CopyTo(plaintext);
- }
-
return UnwrapResult.Success(plaintext, header);
}
finally
@@ -366,6 +451,23 @@ public async ValueTask TryUnwrapAsync(
}
}
+ private static bool SatisfiesRequiredSecurity(
+ MessageSecurityMode requiredMode,
+ UadpSecurityFlagsEncodingMask flags)
+ {
+ if (requiredMode is not (MessageSecurityMode.Sign
+ or MessageSecurityMode.SignAndEncrypt))
+ {
+ return true;
+ }
+
+ bool signed = (flags & UadpSecurityFlagsEncodingMask.NetworkMessageSigned) != 0;
+ bool encrypted = (flags & UadpSecurityFlagsEncodingMask.NetworkMessageEncrypted) != 0;
+ return requiredMode == MessageSecurityMode.SignAndEncrypt
+ ? signed && encrypted
+ : signed;
+ }
+
private void EmitSecurityEvent(PubSubSecurityEvent securityEvent)
{
if (m_securityEventSink is null)
@@ -383,8 +485,13 @@ private void EmitSecurityEvent(PubSubSecurityEvent securityEvent)
}
}
+ private readonly record struct ReplayIdentity(
+ PublisherId PublisherId,
+ ushort WriterGroupId);
+
///
- /// Outcome of .
+ /// Outcome of
+ /// .
///
public sealed record UnwrapResult
{
diff --git a/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionCoverageTests.cs b/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionCoverageTests.cs
index b0aaa504fe..15b53095c5 100644
--- a/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionCoverageTests.cs
+++ b/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionCoverageTests.cs
@@ -575,6 +575,66 @@ await AwaitBoundedAsync(transport.WaitUntilSentAsync(1), "second action response
});
}
+ [Test]
+ public async Task UnsecuredActionOptInIsIsolatedPerResponderAsync()
+ {
+ var diagnostics = new PubSubDiagnostics(PubSubDiagnosticsLevel.High);
+ var transport = new DatagramHarnessTransport(UadpProfile);
+ var encoder = new CapturingEncoder(UadpProfile);
+ var decoder = new QueueDecoder(UadpProfile);
+ await using PubSubConnection connection = CreateConnection(
+ Config(UadpProfile), new SingleTransportFactory(transport, UadpProfile),
+ EncMap(UadpProfile, encoder), DecMap(UadpProfile, decoder), diagnostics);
+
+ int permissiveInvocations = 0;
+ int protectedInvocations = 0;
+ connection.RegisterActionHandler(
+ new PubSubActionTarget { DataSetWriterId = 5, ActionTargetId = 3 },
+ new DelegatePubSubActionHandler((_, _) =>
+ {
+ Interlocked.Increment(ref permissiveInvocations);
+ return new ValueTask(
+ new PubSubActionHandlerResult { StatusCode = StatusCodes.Good });
+ }),
+ allowUnsecured: true);
+ connection.RegisterActionHandler(
+ new PubSubActionTarget { DataSetWriterId = 7, ActionTargetId = 4 },
+ new DelegatePubSubActionHandler((_, _) =>
+ {
+ Interlocked.Increment(ref protectedInvocations);
+ return new ValueTask(
+ new PubSubActionHandlerResult { StatusCode = StatusCodes.Good });
+ }));
+
+ await connection.EnableAsync().ConfigureAwait(false);
+ Deliver(transport, decoder, new UadpActionRequestMessage
+ {
+ DataSetWriterId = 7,
+ ActionTargetId = 4,
+ RequestId = 1,
+ ResponseAddress = string.Empty
+ });
+ Deliver(transport, decoder, new UadpActionRequestMessage
+ {
+ DataSetWriterId = 5,
+ ActionTargetId = 3,
+ RequestId = 2,
+ ResponseAddress = string.Empty
+ });
+
+ await AwaitBoundedAsync(transport.WaitUntilProcessedAsync(2), "two Action requests")
+ .ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(permissiveInvocations, Is.EqualTo(1));
+ Assert.That(protectedInvocations, Is.Zero);
+ Assert.That(transport.SentPayloads, Has.Count.EqualTo(1));
+ Assert.That(
+ diagnostics.Read(PubSubDiagnosticsCounterKind.SecurityTokenErrors),
+ Is.EqualTo(1));
+ });
+ }
+
[Test]
public async Task InvokeActionAsyncRoundTripsUadpResponseAsync()
{
@@ -680,6 +740,44 @@ await AwaitBoundedAsync(transport.WaitUntilProcessedAsync(1), "unsecured json ac
});
}
+ [Test]
+ public async Task ReceiveLoopJsonActionResponderWithoutAllowUnsecuredRecordsSecurityFailureAsync()
+ {
+ var diagnostics = new PubSubDiagnostics(PubSubDiagnosticsLevel.High);
+ var transport = new DatagramHarnessTransport(JsonProfile);
+ var encoder = new CapturingEncoder(JsonProfile);
+ var decoder = new QueueDecoder(JsonProfile);
+ await using PubSubConnection connection = CreateConnection(
+ Config(JsonProfile), new SingleTransportFactory(transport, JsonProfile),
+ EncMap(JsonProfile, encoder), DecMap(JsonProfile, decoder), diagnostics);
+
+ int invocations = 0;
+ connection.RegisterActionHandler(
+ new PubSubActionTarget { DataSetWriterId = 5, ActionTargetId = 3 },
+ new DelegatePubSubActionHandler((_, _) =>
+ {
+ Interlocked.Increment(ref invocations);
+ return new ValueTask(
+ new PubSubActionHandlerResult { StatusCode = StatusCodes.Good });
+ }));
+
+ await connection.EnableAsync().ConfigureAwait(false);
+ Deliver(transport, decoder, NewJsonActionRequest(5, 3, requestId: 1));
+
+ await AwaitBoundedAsync(
+ transport.WaitUntilProcessedAsync(1),
+ "json action request to a responder that forbids unsecured requests")
+ .ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(invocations, Is.Zero);
+ Assert.That(transport.SentPayloads, Is.Empty);
+ Assert.That(
+ diagnostics.Read(PubSubDiagnosticsCounterKind.SecurityTokenErrors),
+ Is.EqualTo(1));
+ });
+ }
+
[Test]
public async Task ReceiveLoopJsonActionRequestWithoutResponderDropsAsync()
{
diff --git a/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionPrivateMethodTests.cs b/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionPrivateMethodTests.cs
index 77ff658372..2398a0b7e9 100644
--- a/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionPrivateMethodTests.cs
+++ b/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionPrivateMethodTests.cs
@@ -577,6 +577,8 @@ public async Task TryUnwrapInboundAsync_WhenSecurityWrapperRejects_ReturnsNullAs
"TryUnwrapInboundAsync",
wrapped,
prefix.Length,
+ PublisherId.FromUInt16(1),
+ (ushort)1,
MessageSecurityMode.None,
CancellationToken.None).ConfigureAwait(false);
diff --git a/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionSecurityReceiveTests.cs b/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionSecurityReceiveTests.cs
index b32418a764..6fc70542e4 100644
--- a/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionSecurityReceiveTests.cs
+++ b/tests/Opc.Ua.PubSub.Tests/Connections/PubSubConnectionSecurityReceiveTests.cs
@@ -222,6 +222,32 @@ public async Task SecuredReaderAcceptsSecuredChunkedFrameAsync()
"unwrap and decode (SA-REGR-01 legit secured+chunked path).");
}
+ [Test]
+ public async Task OpportunisticReaderUnwrapsSecuredFrameWhenSecurityNotRequiredAsync()
+ {
+ // A reader configured with SecurityMode.None but supplied with a
+ // security wrapper unwraps a secured inbound frame opportunistically
+ // (requiredMode None) instead of dropping it.
+ (UadpSecurityWrapper publisher, UadpSecurityWrapper subscriber) =
+ CreateMatchingWrapperPair(tokenId: 1U);
+
+ byte[] secured = await BuildSecuredFrameAsync(publisher).ConfigureAwait(false);
+ var transport = new ProgrammableTransport([secured]);
+ var decoder = new RecordingDecoder();
+
+ await using PubSubConnection conn = NewConnection(
+ transport, decoder, subscriber,
+ MessageSecurityMode.None);
+
+ await conn.EnableAsync().ConfigureAwait(false);
+ await transport.WaitUntilDrainedAsync().ConfigureAwait(false);
+ await conn.DisableAsync().ConfigureAwait(false);
+
+ Assert.That(decoder.CallCount, Is.GreaterThanOrEqualTo(1),
+ "A reader that does not require security must still unwrap and decode " +
+ "a secured frame when a security wrapper is available.");
+ }
+
private static byte[][] ChunkFrames(byte[] message)
{
int maxFrameSize = UadpChunker.ChunkHeaderSize +
diff --git a/tests/Opc.Ua.PubSub.Tests/Security/SecurityTokenWindowTests.cs b/tests/Opc.Ua.PubSub.Tests/Security/SecurityTokenWindowTests.cs
index 23f50e942f..1cb594863a 100644
--- a/tests/Opc.Ua.PubSub.Tests/Security/SecurityTokenWindowTests.cs
+++ b/tests/Opc.Ua.PubSub.Tests/Security/SecurityTokenWindowTests.cs
@@ -31,6 +31,7 @@
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
@@ -100,10 +101,54 @@ 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, MakeNonce(1)), Is.True);
- Assert.That(window.TryAccept(2U, 5UL, MakeNonce(2)), Is.True);
+ 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);
});
}
@@ -116,6 +161,50 @@ public void RetireToken_RejectsSubsequentMessages()
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()
{
@@ -238,15 +327,36 @@ public void Constructor_RejectsNonPositiveHistorySize()
Throws.TypeOf());
}
+ [Test]
+ public void ConstructorRejectsNonPositiveNonceFilterSize()
+ {
+ Assert.That(
+ () => new SecurityTokenWindow(
+ historySize: 1024,
+ timeProvider: null,
+ nonceFilterSizeInBytes: 0),
+ Throws.TypeOf());
+ Assert.That(
+ () => new SecurityTokenWindow(
+ historySize: 1024,
+ timeProvider: null,
+ nonceFilterSizeInBytes: -1),
+ Throws.TypeOf());
+ }
+
[Test]
public void Properties_ReflectConfiguration()
{
TimeProvider clock = TimeProvider.System;
- var window = new SecurityTokenWindow(historySize: 16, timeProvider: clock);
+ 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));
});
}
diff --git a/tests/Opc.Ua.PubSub.Tests/Security/UadpSecurityWrapperReplayTests.cs b/tests/Opc.Ua.PubSub.Tests/Security/UadpSecurityWrapperReplayTests.cs
index 2389e564b6..fa3c52d15d 100644
--- a/tests/Opc.Ua.PubSub.Tests/Security/UadpSecurityWrapperReplayTests.cs
+++ b/tests/Opc.Ua.PubSub.Tests/Security/UadpSecurityWrapperReplayTests.cs
@@ -28,6 +28,7 @@
* ======================================================================*/
using System;
+using System.Security.Cryptography;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.PubSub.Encoding;
@@ -60,7 +61,7 @@ public class UadpSecurityWrapperReplayTests
private static (UadpSecurityWrapper Sender, UadpSecurityWrapper Receiver)
CreatePair(
- PubSubAes256CtrPolicy policy,
+ IPubSubSecurityPolicy policy,
int receiverHistorySize,
ulong senderCap = RandomNonceProvider.DefaultMaxMessagesPerKey)
{
@@ -178,6 +179,238 @@ public async Task SendSideCapForcesRolloverBeforeNonceRepetitionAsync()
Throws.TypeOf());
}
+ [Test]
+ public async Task ReplayStateIsScopedByPublisherWriterGroupAndTokenAsync()
+ {
+ PubSubAes256CtrPolicy policy = PubSubAes256CtrPolicy.Instance;
+ PubSubSecurityKey key = TestSecurityKeyFactory.Create(
+ TokenId,
+ signingKeyLength: policy.SigningKeyLength,
+ encryptingKeyLength: policy.EncryptingKeyLength,
+ keyNonceLength: policy.NonceLength);
+ PublisherId publisherA = PublisherId.FromUInt32(100U);
+ PublisherId publisherB = PublisherId.FromUInt32(200U);
+ UadpSecurityWrapper senderAGroup1 = CreateWrapper(policy, key, publisherA);
+ UadpSecurityWrapper senderBGroup1 = CreateWrapper(policy, key, publisherB);
+ UadpSecurityWrapper senderAGroup2 = CreateWrapper(policy, key, publisherA);
+ var receiverWindow = new SecurityTokenWindow();
+ receiverWindow.RegisterToken(TokenId);
+ UadpSecurityWrapper receiver = CreateWrapper(
+ policy,
+ key,
+ PublisherId.FromUInt32(999U),
+ receiverWindow);
+
+ ReadOnlyMemory publisherAGroup1 = await senderAGroup1
+ .WrapAsync(s_outerPrefix, s_innerPayload)
+ .ConfigureAwait(false);
+ ReadOnlyMemory publisherBGroup1 = await senderBGroup1
+ .WrapAsync(s_outerPrefix, s_innerPayload)
+ .ConfigureAwait(false);
+ ReadOnlyMemory publisherAGroup2 = await senderAGroup2
+ .WrapAsync(s_outerPrefix, s_innerPayload)
+ .ConfigureAwait(false);
+
+ UadpSecurityWrapper.UnwrapResult first = await UnwrapScopedAsync(
+ receiver, publisherAGroup1, publisherA, writerGroupId: 1).ConfigureAwait(false);
+ UadpSecurityWrapper.UnwrapResult secondPublisher = await UnwrapScopedAsync(
+ receiver, publisherBGroup1, publisherB, writerGroupId: 1).ConfigureAwait(false);
+ UadpSecurityWrapper.UnwrapResult secondGroup = await UnwrapScopedAsync(
+ receiver, publisherAGroup2, publisherA, writerGroupId: 2).ConfigureAwait(false);
+ UadpSecurityWrapper.UnwrapResult replay = await UnwrapScopedAsync(
+ receiver, publisherAGroup1, publisherA, writerGroupId: 1).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(first.IsSuccess, Is.True, first.Reason);
+ Assert.That(secondPublisher.IsSuccess, Is.True, secondPublisher.Reason);
+ Assert.That(secondGroup.IsSuccess, Is.True, secondGroup.Reason);
+ Assert.That(replay.IsSuccess, Is.False);
+ });
+ }
+
+ [Test]
+ public async Task RequiredModeRejectionDoesNotPoisonReplayStateAsync()
+ {
+ PubSubAes256CtrPolicy policy = PubSubAes256CtrPolicy.Instance;
+ PubSubSecurityKey key = TestSecurityKeyFactory.Create(
+ TokenId,
+ signingKeyLength: policy.SigningKeyLength,
+ encryptingKeyLength: policy.EncryptingKeyLength,
+ keyNonceLength: policy.NonceLength);
+ PublisherId publisherId = PublisherId.FromUInt32(300U);
+ UadpSecurityWrapper signOnlySender = CreateWrapper(policy, key, publisherId);
+ UadpSecurityWrapper securedSender = CreateWrapper(policy, key, publisherId);
+ var receiverWindow = new SecurityTokenWindow();
+ receiverWindow.RegisterToken(TokenId);
+ UadpSecurityWrapper receiver = CreateWrapper(
+ policy,
+ key,
+ PublisherId.FromUInt32(999U),
+ receiverWindow);
+
+ ReadOnlyMemory signOnly = await signOnlySender
+ .WrapAsync(s_outerPrefix, s_innerPayload, UadpSecurityWrapOptions.SignOnly)
+ .ConfigureAwait(false);
+ ReadOnlyMemory secured = await securedSender
+ .WrapAsync(s_outerPrefix, s_innerPayload, UadpSecurityWrapOptions.SignAndEncrypt)
+ .ConfigureAwait(false);
+
+ UadpSecurityWrapper.UnwrapResult rejected = await UnwrapScopedAsync(
+ receiver,
+ signOnly,
+ publisherId,
+ writerGroupId: 7,
+ MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
+ UadpSecurityWrapper.UnwrapResult accepted = await UnwrapScopedAsync(
+ receiver,
+ secured,
+ publisherId,
+ writerGroupId: 7,
+ MessageSecurityMode.SignAndEncrypt).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(rejected.IsSuccess, Is.False);
+ Assert.That(rejected.Status, Is.EqualTo(StatusCodes.BadSecurityModeRejected));
+ Assert.That(accepted.IsSuccess, Is.True, accepted.Reason);
+ });
+ }
+
+ [Test]
+ public async Task FailedDecryptionDoesNotCommitReplayStateAsync()
+ {
+ var policy = new FailFirstDecryptPolicy(PubSubAes256CtrPolicy.Instance);
+ (UadpSecurityWrapper sender, UadpSecurityWrapper receiver) =
+ CreatePair(policy, receiverHistorySize: 64);
+ ReadOnlyMemory wrapped = await sender
+ .WrapAsync(s_outerPrefix, s_innerPayload)
+ .ConfigureAwait(false);
+
+ UadpSecurityWrapper.UnwrapResult rejected = await receiver
+ .TryUnwrapAsync(s_outerPrefix.AsMemory(), wrapped[s_outerPrefix.Length..])
+ .ConfigureAwait(false);
+
+ UadpSecurityWrapper.UnwrapResult result = await receiver
+ .TryUnwrapAsync(s_outerPrefix.AsMemory(), wrapped[s_outerPrefix.Length..])
+ .ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(rejected.IsSuccess, Is.False);
+ Assert.That(rejected.Status, Is.EqualTo(StatusCodes.BadSecurityChecksFailed));
+ Assert.That(result.IsSuccess, Is.True, result.Reason);
+ });
+ }
+
+ [Test]
+ public async Task LegacyTokenWindowUsedWhenScopedInterfaceUnavailableAsync()
+ {
+ PubSubAes256CtrPolicy policy = PubSubAes256CtrPolicy.Instance;
+ PubSubSecurityKey key = TestSecurityKeyFactory.Create(
+ TokenId,
+ signingKeyLength: policy.SigningKeyLength,
+ encryptingKeyLength: policy.EncryptingKeyLength,
+ keyNonceLength: policy.NonceLength);
+ PublisherId publisherId = PublisherId.FromUInt32(321U);
+ UadpSecurityWrapper sender = CreateWrapper(policy, key, publisherId);
+
+ // A token window that does not implement IScopedSecurityTokenWindow
+ // must fall back to the legacy token-only replay check.
+ var legacyWindow = new RecordingTokenWindow();
+ var receiverRing = new PubSubSecurityKeyRing("group");
+ receiverRing.SetCurrent(key);
+ var receiver = new UadpSecurityWrapper(
+ policy,
+ new StaticSecurityKeyProvider("group", receiverRing),
+ new RandomNonceProvider(PublisherId.FromUInt32(999U)),
+ legacyWindow,
+ NUnitTelemetryContext.Create());
+
+ ReadOnlyMemory wrapped = await sender
+ .WrapAsync(s_outerPrefix, s_innerPayload, UadpSecurityWrapOptions.SignAndEncrypt)
+ .ConfigureAwait(false);
+
+ UadpSecurityWrapper.UnwrapResult result = await UnwrapScopedAsync(
+ receiver, wrapped, publisherId, writerGroupId: 11).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result.IsSuccess, Is.True, result.Reason);
+ Assert.That(legacyWindow.AcceptCount, Is.EqualTo(1));
+ Assert.That(legacyWindow.AcceptedTokenId, Is.EqualTo(TokenId));
+ });
+ }
+
+ [Test]
+ public async Task UnsignedScopedFrameIsAcceptedWithoutConsumingReplayStateAsync()
+ {
+ PubSubAes256CtrPolicy policy = PubSubAes256CtrPolicy.Instance;
+ PubSubSecurityKey key = TestSecurityKeyFactory.Create(
+ TokenId,
+ signingKeyLength: policy.SigningKeyLength,
+ encryptingKeyLength: policy.EncryptingKeyLength,
+ keyNonceLength: policy.NonceLength);
+ PublisherId publisherId = PublisherId.FromUInt32(654U);
+ UadpSecurityWrapper sender = CreateWrapper(policy, key, publisherId);
+ var receiverWindow = new SecurityTokenWindow();
+ receiverWindow.RegisterToken(TokenId);
+ UadpSecurityWrapper receiver = CreateWrapper(
+ policy, key, PublisherId.FromUInt32(999U), receiverWindow);
+
+ ReadOnlyMemory wrapped = await sender
+ .WrapAsync(s_outerPrefix, s_innerPayload, UadpSecurityWrapOptions.EncryptOnly)
+ .ConfigureAwait(false);
+
+ // An unsigned frame carries no authenticated PublisherId, WriterGroupId
+ // or nonce, so it must never mutate the authenticated replay window.
+ // Unwrapping the same encrypt-only frame twice therefore succeeds both
+ // times instead of being flagged as a replay.
+ UadpSecurityWrapper.UnwrapResult first = await UnwrapScopedAsync(
+ receiver, wrapped, publisherId, writerGroupId: 3, MessageSecurityMode.None)
+ .ConfigureAwait(false);
+ UadpSecurityWrapper.UnwrapResult second = await UnwrapScopedAsync(
+ receiver, wrapped, publisherId, writerGroupId: 3, MessageSecurityMode.None)
+ .ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(first.IsSuccess, Is.True, first.Reason);
+ Assert.That(first.InnerPayload!.Value.ToArray(), Is.EqualTo(s_innerPayload));
+ Assert.That(second.IsSuccess, Is.True, second.Reason);
+ });
+ }
+
+ private static UadpSecurityWrapper CreateWrapper(
+ PubSubAes256CtrPolicy policy,
+ PubSubSecurityKey key,
+ PublisherId publisherId,
+ SecurityTokenWindow? window = null)
+ {
+ var ring = new PubSubSecurityKeyRing("group");
+ ring.SetCurrent(key);
+ return new UadpSecurityWrapper(
+ policy,
+ new StaticSecurityKeyProvider("group", ring),
+ new RandomNonceProvider(publisherId),
+ window ?? new SecurityTokenWindow(),
+ NUnitTelemetryContext.Create());
+ }
+
+ private static ValueTask UnwrapScopedAsync(
+ UadpSecurityWrapper receiver,
+ ReadOnlyMemory wrapped,
+ PublisherId publisherId,
+ ushort writerGroupId,
+ MessageSecurityMode requiredMode = MessageSecurityMode.SignAndEncrypt)
+ {
+ return receiver.TryUnwrapAsync(
+ s_outerPrefix.AsMemory(),
+ wrapped[s_outerPrefix.Length..],
+ publisherId,
+ writerGroupId,
+ requiredMode);
+ }
+
private static (ulong SequenceNumber, byte[] Nonce) ReadNonce(
ReadOnlyMemory wrapped)
{
@@ -190,5 +423,88 @@ private static (ulong SequenceNumber, byte[] Nonce) ReadNonce(
(_, ulong sequenceNumber) = AesCtrNonceLayout.Parse(nonce);
return (sequenceNumber, nonce);
}
+
+ private sealed class RecordingTokenWindow : ISecurityTokenWindow
+ {
+ public int AcceptCount { get; private set; }
+
+ public uint AcceptedTokenId { get; private set; }
+
+ public ulong AcceptedSequenceNumber { get; private set; }
+
+ public bool TryAccept(uint tokenId, ulong sequenceNumber, ReadOnlySpan nonce)
+ {
+ AcceptCount++;
+ AcceptedTokenId = tokenId;
+ AcceptedSequenceNumber = sequenceNumber;
+ return true;
+ }
+
+ public void Reset()
+ {
+ AcceptCount = 0;
+ }
+ }
+
+ private sealed class FailFirstDecryptPolicy : IPubSubSecurityPolicy
+ {
+ public FailFirstDecryptPolicy(IPubSubSecurityPolicy inner)
+ {
+ m_inner = inner;
+ }
+
+ public string PolicyUri => m_inner.PolicyUri;
+
+ public int SigningKeyLength => m_inner.SigningKeyLength;
+
+ public int EncryptingKeyLength => m_inner.EncryptingKeyLength;
+
+ public int NonceLength => m_inner.NonceLength;
+
+ public int SignatureLength => m_inner.SignatureLength;
+
+ public void Sign(
+ ReadOnlySpan data,
+ ReadOnlySpan signingKey,
+ Span signature)
+ {
+ m_inner.Sign(data, signingKey, signature);
+ }
+
+ public bool Verify(
+ ReadOnlySpan data,
+ ReadOnlySpan signature,
+ ReadOnlySpan signingKey)
+ {
+ return m_inner.Verify(data, signature, signingKey);
+ }
+
+ public void Encrypt(
+ ReadOnlySpan plaintext,
+ ReadOnlySpan encryptingKey,
+ ReadOnlySpan nonce,
+ Span ciphertext)
+ {
+ m_inner.Encrypt(plaintext, encryptingKey, nonce, ciphertext);
+ }
+
+ public void Decrypt(
+ ReadOnlySpan ciphertext,
+ ReadOnlySpan encryptingKey,
+ ReadOnlySpan nonce,
+ Span plaintext)
+ {
+ if (m_failNextDecrypt)
+ {
+ m_failNextDecrypt = false;
+ throw new CryptographicException("Simulated authenticated-decryption failure.");
+ }
+
+ m_inner.Decrypt(ciphertext, encryptingKey, nonce, plaintext);
+ }
+
+ private readonly IPubSubSecurityPolicy m_inner;
+ private bool m_failNextDecrypt = true;
+ }
}
}
diff --git a/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsDatagramTransportPeerTests.cs b/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsDatagramTransportPeerTests.cs
new file mode 100644
index 0000000000..0e3a356dcc
--- /dev/null
+++ b/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsDatagramTransportPeerTests.cs
@@ -0,0 +1,389 @@
+#if NET8_0_OR_GREATER
+/* ========================================================================
+ * 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.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.PubSub.Diagnostics;
+using Opc.Ua.PubSub.Tests;
+using Opc.Ua.PubSub.Transports;
+using Opc.Ua.PubSub.Udp.Dtls;
+using Opc.Ua.Tests;
+
+namespace Opc.Ua.PubSub.Udp.Tests.Dtls
+{
+ [TestFixture]
+ [Category("Integration")]
+ [CancelAfter(10000)]
+ [TestSpec("RFC 9147 §4.5.2")]
+ public sealed class DtlsDatagramTransportPeerTests
+ {
+ [Test]
+ public async Task AuthenticatedRecordFromDifferentSourceDoesNotRedirectPinnedPeerAsync()
+ {
+ int port;
+ try
+ {
+ port = UdpIntegrationTestHelpers.ReserveEphemeralPort(IPAddress.Loopback);
+ }
+ catch (SocketException ex)
+ {
+ Assert.Ignore($"Loopback UDP socket bind failed: {ex.Message}");
+ return;
+ }
+
+ DtlsProfile profile = CreateProfile();
+ string url = $"opc.dtls://127.0.0.1:{port}";
+ var endpoint = new UdpEndpoint(
+ IPAddress.Loopback,
+ port,
+ UdpAddressType.Unicast,
+ url,
+ IsDtls: true,
+ DtlsProfileName: profile.Name);
+ var diagnostics = new PubSubDiagnostics(PubSubDiagnosticsLevel.High);
+ using var authenticatedSocket = NewLoopbackSocket();
+ using var spoofSocket = NewLoopbackSocket();
+ var authenticatedEndpoint = (IPEndPoint)authenticatedSocket.LocalEndPoint!;
+ var contextFactory = new MarkerContextFactory(authenticatedEndpoint);
+ await using var transport = new DtlsDatagramTransport(
+ UdpIntegrationTestHelpers.NewConnection(url, "dtls-peer"),
+ endpoint,
+ PubSubTransportDirection.Receive,
+ networkInterface: null,
+ NUnitTelemetryContext.Create(),
+ TimeProvider.System,
+ UdpIntegrationTestHelpers.LoopbackOptions(),
+ diagnostics,
+ contextFactory,
+ profile);
+
+ try
+ {
+ await transport.OpenAsync().ConfigureAwait(false);
+ }
+ catch (SocketException ex)
+ {
+ Assert.Ignore($"DTLS loopback open failed: {ex.Message}");
+ return;
+ }
+
+ var destination = new IPEndPoint(IPAddress.Loopback, port);
+ Task receiveTask = UdpIntegrationTestHelpers.ReceiveOneAsync(
+ transport,
+ TimeSpan.FromSeconds(5));
+
+ Assert.That(transport.RemoteEndpoint, Is.EqualTo(authenticatedEndpoint));
+ await spoofSocket.SendToAsync(
+ new byte[] { MarkerContext.Marker, 0x55 },
+ SocketFlags.None,
+ destination).ConfigureAwait(false);
+ await WaitForReceivedCountAsync(diagnostics, expectedCount: 1).ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(contextFactory.UnprotectCount, Is.Zero);
+ Assert.That(transport.RemoteEndpoint, Is.EqualTo(authenticatedEndpoint));
+ });
+
+ await authenticatedSocket.SendToAsync(
+ new byte[] { MarkerContext.Marker, 0x55 },
+ SocketFlags.None,
+ destination).ConfigureAwait(false);
+ PubSubTransportFrame? frame = await receiveTask.ConfigureAwait(false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(frame, Is.Not.Null);
+ Assert.That(frame!.Value.Payload.ToArray(), Is.EqualTo(new byte[] { 0x55 }));
+ Assert.That(frame.Value.SourceEndpoint, Is.EqualTo(authenticatedEndpoint));
+ Assert.That(transport.RemoteEndpoint, Is.EqualTo(authenticatedEndpoint));
+ Assert.That(contextFactory.UnprotectCount, Is.EqualTo(1));
+ });
+ }
+
+ [Test]
+ public async Task SetAuthenticatedPeerRejectsNullPeerAsync()
+ {
+ await using DtlsDatagramTransport transport = CreateReceiveTransport();
+ var channel = (IDtlsAuthenticatedPeerChannel)transport;
+ Assert.That(
+ () => channel.SetAuthenticatedPeer(null!),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public async Task SetAuthenticatedPeerRejectsConflictingRebindAsync()
+ {
+ await using DtlsDatagramTransport transport = CreateReceiveTransport();
+ var channel = (IDtlsAuthenticatedPeerChannel)transport;
+ channel.SetAuthenticatedPeer(new IPEndPoint(IPAddress.Loopback, 5001));
+ Assert.Multiple(() =>
+ {
+ // Re-pinning the identical endpoint is idempotent.
+ Assert.That(
+ () => channel.SetAuthenticatedPeer(new IPEndPoint(IPAddress.Loopback, 5001)),
+ Throws.Nothing);
+ // Without a negotiated connection ID the association cannot move
+ // to a different peer endpoint.
+ Assert.That(
+ () => channel.SetAuthenticatedPeer(new IPEndPoint(IPAddress.Loopback, 5002)),
+ Throws.TypeOf());
+ });
+ }
+
+ [Test]
+ public async Task CloseClearsAuthenticatedPeerAsync()
+ {
+ int port;
+ try
+ {
+ port = UdpIntegrationTestHelpers.ReserveEphemeralPort(IPAddress.Loopback);
+ }
+ catch (SocketException ex)
+ {
+ Assert.Ignore($"Loopback UDP socket bind failed: {ex.Message}");
+ return;
+ }
+
+ DtlsProfile profile = CreateProfile();
+ string url = $"opc.dtls://127.0.0.1:{port}";
+ var endpoint = new UdpEndpoint(
+ IPAddress.Loopback,
+ port,
+ UdpAddressType.Unicast,
+ url,
+ IsDtls: true,
+ DtlsProfileName: profile.Name);
+ using var authenticatedSocket = NewLoopbackSocket();
+ var authenticatedEndpoint = (IPEndPoint)authenticatedSocket.LocalEndPoint!;
+ var contextFactory = new MarkerContextFactory(authenticatedEndpoint);
+ await using var transport = new DtlsDatagramTransport(
+ UdpIntegrationTestHelpers.NewConnection(url, "dtls-peer-close"),
+ endpoint,
+ PubSubTransportDirection.Receive,
+ networkInterface: null,
+ NUnitTelemetryContext.Create(),
+ TimeProvider.System,
+ UdpIntegrationTestHelpers.LoopbackOptions(),
+ diagnostics: null,
+ contextFactory,
+ profile);
+
+ try
+ {
+ await transport.OpenAsync().ConfigureAwait(false);
+ }
+ catch (SocketException ex)
+ {
+ Assert.Ignore($"DTLS loopback open failed: {ex.Message}");
+ return;
+ }
+
+ Assert.That(transport.RemoteEndpoint, Is.EqualTo(authenticatedEndpoint));
+
+ // Closing tears down the association and clears the pinned peer so a
+ // fresh handshake is required before any further records are accepted.
+ Assert.That(
+ async () => await transport.CloseAsync().ConfigureAwait(false),
+ Throws.Nothing);
+ }
+
+ private static DtlsDatagramTransport CreateReceiveTransport(int port = 44444)
+ {
+ DtlsProfile profile = CreateProfile();
+ string url = $"opc.dtls://127.0.0.1:{port}";
+ var endpoint = new UdpEndpoint(
+ IPAddress.Loopback,
+ port,
+ UdpAddressType.Unicast,
+ url,
+ IsDtls: true,
+ DtlsProfileName: profile.Name);
+ return new DtlsDatagramTransport(
+ UdpIntegrationTestHelpers.NewConnection(url, "dtls-peer-unit"),
+ endpoint,
+ PubSubTransportDirection.Receive,
+ networkInterface: null,
+ NUnitTelemetryContext.Create(),
+ TimeProvider.System,
+ UdpIntegrationTestHelpers.LoopbackOptions(),
+ diagnostics: null,
+ new MarkerContextFactory(new IPEndPoint(IPAddress.Loopback, port)),
+ profile);
+ }
+
+ private static Socket NewLoopbackSocket()
+ {
+ var socket = new Socket(
+ AddressFamily.InterNetwork,
+ SocketType.Dgram,
+ ProtocolType.Udp);
+ socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
+ return socket;
+ }
+
+ private static async Task WaitForReceivedCountAsync(
+ PubSubDiagnostics diagnostics,
+ long expectedCount)
+ {
+ TimeSpan timeout = TimeSpan.FromSeconds(3);
+ var stopwatch = Stopwatch.StartNew();
+ while (true)
+ {
+ long observedCount = diagnostics.Read(
+ PubSubDiagnosticsCounterKind.ReceivedNetworkMessages);
+ if (observedCount >= expectedCount)
+ {
+ return;
+ }
+ if (stopwatch.Elapsed >= timeout)
+ {
+ Assert.Fail(
+ $"Timed out after {timeout} waiting for at least {expectedCount} received " +
+ $"network message(s); observed {observedCount}.");
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(10)).ConfigureAwait(false);
+ }
+ }
+
+ private static DtlsProfile CreateProfile()
+ {
+ return new DtlsProfile(
+ "test-aead",
+ DtlsCipherSuite.TlsAes128GcmSha256,
+ DtlsNamedCurve.NistP256,
+ DtlsNamedCurve.NistP256,
+ isMandatory: false);
+ }
+
+ private sealed class MarkerContextFactory : IDtlsContextFactory
+ {
+ public MarkerContextFactory(IPEndPoint authenticatedPeer)
+ {
+ m_authenticatedPeer = authenticatedPeer;
+ }
+
+ public int UnprotectCount => Volatile.Read(ref m_unprotectCount);
+
+ public ValueTask CreateAsync(
+ PubSubConnectionDataType connection,
+ UdpEndpoint endpoint,
+ DtlsProfile profile,
+ ITelemetryContext telemetry,
+ TimeProvider timeProvider,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return new ValueTask(
+ new MarkerContext(profile, m_authenticatedPeer, RecordUnprotect));
+ }
+
+ private void RecordUnprotect()
+ {
+ Interlocked.Increment(ref m_unprotectCount);
+ }
+
+ private readonly IPEndPoint m_authenticatedPeer;
+ private int m_unprotectCount;
+ }
+
+ private sealed class MarkerContext : IDtlsContext
+ {
+ public const byte Marker = 0xA5;
+
+ public MarkerContext(
+ DtlsProfile profile,
+ IPEndPoint authenticatedPeer,
+ Action recordUnprotect)
+ {
+ Profile = profile;
+ m_authenticatedPeer = authenticatedPeer;
+ m_recordUnprotect = recordUnprotect;
+ }
+
+ public DtlsProfile Profile { get; }
+
+ public ValueTask OpenAsync(
+ IDtlsDatagramChannel channel,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (channel is not IDtlsAuthenticatedPeerChannel peerChannel)
+ {
+ throw new InvalidOperationException(
+ "The test DTLS channel does not support authenticated peer pinning.");
+ }
+
+ peerChannel.SetAuthenticatedPeer(m_authenticatedPeer);
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask> ProtectAsync(
+ ReadOnlyMemory payload,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ byte[] record = new byte[payload.Length + 1];
+ record[0] = Marker;
+ payload.Span.CopyTo(record.AsSpan(1));
+ return new ValueTask>(record);
+ }
+
+ public ValueTask> UnprotectAsync(
+ ReadOnlyMemory record,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ m_recordUnprotect();
+ if (record.IsEmpty || record.Span[0] != Marker)
+ {
+ throw new CryptographicException("Unauthenticated test record.");
+ }
+
+ return new ValueTask>(record[1..]);
+ }
+
+ public void Dispose()
+ {
+ }
+
+ private readonly IPEndPoint m_authenticatedPeer;
+ private readonly Action m_recordUnprotect;
+ }
+ }
+}
+#endif
diff --git a/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsHandshakeContextTests.cs b/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsHandshakeContextTests.cs
index 8df4b98a60..a0107b5485 100644
--- a/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsHandshakeContextTests.cs
+++ b/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsHandshakeContextTests.cs
@@ -348,6 +348,51 @@ public async Task MutualAuthenticationFailsClosedWhenClientHasNoCertificateAsync
Assert.That(async () => await serverTask.ConfigureAwait(false), Throws.Exception);
}
+ [Test]
+ public async Task ServerRejectsHandshakeCompletionFromDifferentEndpointAsync()
+ {
+ DtlsProfile profile = ResolveOrIgnore("ECC_nistP256_AesGcm");
+ using Certificate certificate = CreateEcdsaCertificate(profile.CertificateCurve);
+ Mock validator = CreateSuccessfulValidator();
+ var spoofedEndpoint = new IPEndPoint(IPAddress.Loopback, 56000);
+ (InMemoryDtlsDatagramChannel Client, InMemoryDtlsDatagramChannel Server) pair =
+ InMemoryDtlsDatagramChannel.CreatePair(
+ clientToServerSourceSelector: (datagram, source) =>
+ datagram[0] == (byte)DtlsHandshakeType.ClientHello
+ ? source
+ : spoofedEndpoint);
+ var clientOptions = new DtlsTransportOptions
+ {
+ PeerCertificateValidator = validator.Object,
+ RequireHelloRetryRequestCookie = true,
+ InitialRetransmissionTimeout = TimeSpan.FromSeconds(1),
+ MaxRetransmissionTimeout = TimeSpan.FromSeconds(1)
+ };
+ clientOptions.LocalCertificates.Add(certificate);
+ var serverOptions = new DtlsTransportOptions
+ {
+ PeerCertificateValidator = validator.Object,
+ RequireHelloRetryRequestCookie = true,
+ InitialRetransmissionTimeout = TimeSpan.FromMilliseconds(250),
+ MaxRetransmissionTimeout = TimeSpan.FromMilliseconds(250)
+ };
+ serverOptions.LocalCertificates.Add(certificate);
+ using DtlsHandshakeContext client = CreateContext(
+ profile, DtlsEndpointRole.Client, clientOptions, validator.Object);
+ using DtlsHandshakeContext server = CreateContext(
+ profile, DtlsEndpointRole.Server, serverOptions, validator.Object);
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
+
+ Task clientTask = client.OpenAsync(pair.Client, cts.Token).AsTask();
+ Task serverTask = server.OpenAsync(pair.Server, cts.Token).AsTask();
+
+ await clientTask.ConfigureAwait(false);
+ Assert.That(
+ async () => await serverTask.ConfigureAwait(false),
+ Throws.TypeOf());
+ Assert.That(pair.Server.AuthenticatedPeer, Is.Null);
+ }
+
private static async Task RunHandshakeAndApplicationRoundTripAsync(DtlsProfile profile)
{
using Certificate certificate = CreateEcdsaCertificate(profile.CertificateCurve);
@@ -360,6 +405,12 @@ await Task.WhenAll(
client.OpenAsync(pair.Client, CancellationToken.None).AsTask(),
server.OpenAsync(pair.Server, CancellationToken.None).AsTask()).ConfigureAwait(false);
+ Assert.Multiple(() =>
+ {
+ Assert.That(pair.Client.AuthenticatedPeer, Is.EqualTo(pair.Client.RemoteEndpoint));
+ Assert.That(pair.Server.AuthenticatedPeer, Is.EqualTo(pair.Server.RemoteEndpoint));
+ });
+
byte[] payload = [0x55, 0x41, 0x44, 0x50];
ReadOnlyMemory record = await client.ProtectAsync(payload, CancellationToken.None).ConfigureAwait(false);
ReadOnlyMemory plaintext = await server.UnprotectAsync(record, CancellationToken.None).ConfigureAwait(false);
@@ -486,42 +537,58 @@ private static HashAlgorithmName GetHash(DtlsNamedCurve curve)
///
/// In-memory used to drive both ends of a DTLS handshake in tests.
///
- private sealed class InMemoryDtlsDatagramChannel : IDtlsDatagramChannel
+ private sealed class InMemoryDtlsDatagramChannel :
+ IDtlsDatagramChannel,
+ IDtlsAuthenticatedPeerChannel
{
private InMemoryDtlsDatagramChannel(
- Channel> inbound,
- Channel> outbound,
+ Channel inbound,
+ Channel outbound,
+ IPEndPoint localEndpoint,
IPEndPoint remoteEndpoint,
- Func? outboundTransform)
+ Func? outboundTransform,
+ Func? outboundSourceSelector)
{
m_inbound = inbound;
m_outbound = outbound;
+ m_localEndpoint = localEndpoint;
RemoteEndpoint = remoteEndpoint;
m_outboundTransform = outboundTransform;
+ m_outboundSourceSelector = outboundSourceSelector;
}
///
public IPEndPoint? RemoteEndpoint { get; }
+ public IPEndPoint? AuthenticatedPeer { get; private set; }
+
///
/// Creates a connected client/server channel pair backed by in-memory queues.
///
public static (InMemoryDtlsDatagramChannel Client, InMemoryDtlsDatagramChannel Server) CreatePair(
Func? clientToServerTransform = null,
- Func? serverToClientTransform = null)
+ Func? serverToClientTransform = null,
+ Func? clientToServerSourceSelector = null,
+ Func? serverToClientSourceSelector = null)
{
- var clientInbound = Channel.CreateUnbounded>();
- var serverInbound = Channel.CreateUnbounded>();
+ var clientInbound = Channel.CreateUnbounded();
+ var serverInbound = Channel.CreateUnbounded();
+ var clientEndpoint = new IPEndPoint(IPAddress.Loopback, 55000);
+ var serverEndpoint = new IPEndPoint(IPAddress.Loopback, 4843);
var client = new InMemoryDtlsDatagramChannel(
clientInbound,
serverInbound,
- new IPEndPoint(IPAddress.Loopback, 4843),
- clientToServerTransform);
+ clientEndpoint,
+ serverEndpoint,
+ clientToServerTransform,
+ clientToServerSourceSelector);
var server = new InMemoryDtlsDatagramChannel(
serverInbound,
clientInbound,
- new IPEndPoint(IPAddress.Loopback, 55000),
- serverToClientTransform);
+ serverEndpoint,
+ clientEndpoint,
+ serverToClientTransform,
+ serverToClientSourceSelector);
return (client, server);
}
@@ -538,20 +605,29 @@ public ValueTask SendAsync(
copy = m_outboundTransform(copy);
}
- return m_outbound.Writer.WriteAsync(copy, cancellationToken);
+ IPEndPoint source = m_outboundSourceSelector?.Invoke(copy, m_localEndpoint)
+ ?? m_localEndpoint;
+ return m_outbound.Writer.WriteAsync(
+ new DtlsDatagram(copy, source),
+ cancellationToken);
}
///
- public async ValueTask ReceiveAsync(CancellationToken cancellationToken = default)
+ public ValueTask ReceiveAsync(CancellationToken cancellationToken = default)
+ {
+ return m_inbound.Reader.ReadAsync(cancellationToken);
+ }
+
+ void IDtlsAuthenticatedPeerChannel.SetAuthenticatedPeer(IPEndPoint peer)
{
- ReadOnlyMemory payload = await m_inbound.Reader.ReadAsync(cancellationToken)
- .ConfigureAwait(false);
- return new DtlsDatagram(payload, RemoteEndpoint);
+ AuthenticatedPeer = new IPEndPoint(peer.Address, peer.Port);
}
- private readonly Channel> m_inbound;
- private readonly Channel> m_outbound;
+ private readonly Channel m_inbound;
+ private readonly Channel m_outbound;
+ private readonly IPEndPoint m_localEndpoint;
private readonly Func? m_outboundTransform;
+ private readonly Func? m_outboundSourceSelector;
}
///
diff --git a/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsRecordProtectionTests.cs b/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsRecordProtectionTests.cs
index c365646cb4..675ab3a6b9 100644
--- a/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsRecordProtectionTests.cs
+++ b/tests/Opc.Ua.PubSub.Udp.Tests/Dtls/DtlsRecordProtectionTests.cs
@@ -29,7 +29,9 @@
#if NET8_0_OR_GREATER
using System;
+using System.Linq;
using System.Security.Cryptography;
+using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.PubSub.Tests;
using Opc.Ua.PubSub.Udp.Dtls;
@@ -179,6 +181,89 @@ public void SequenceNumberReconstructionSurvivesSixteenBitWraparound()
}
}
+ [Test]
+ public async Task ParallelAeadProtectionSerializesSequenceAndCryptoUseAsync()
+ {
+ const int recordCount = 64;
+ byte[] secret = CreateSecret(DtlsCipherSuite.TlsAes128GcmSha256);
+ using var writer = new DtlsRecordProtection(
+ CreateProfile(DtlsCipherSuite.TlsAes128GcmSha256), secret, epoch: 1);
+ using var reader = new DtlsRecordProtection(
+ CreateProfile(DtlsCipherSuite.TlsAes128GcmSha256), secret, epoch: 1);
+ var startSealing = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ Task[] sealTasks = Enumerable.Range(0, recordCount)
+ .Select(index => Task.Run(async () =>
+ {
+ await startSealing.Task.ConfigureAwait(false);
+ return writer.Seal(BitConverter.GetBytes(index));
+ }))
+ .ToArray();
+
+ startSealing.SetResult(true);
+ byte[][] records = await Task.WhenAll(sealTasks).ConfigureAwait(false);
+
+ var startOpening = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ Task[] openTasks = records
+ .Select(record => Task.Run(async () =>
+ {
+ await startOpening.Task.ConfigureAwait(false);
+ return reader.Open(record);
+ }))
+ .ToArray();
+
+ startOpening.SetResult(true);
+ byte[][] plaintexts = await Task.WhenAll(openTasks).ConfigureAwait(false);
+
+ for (int index = 0; index < plaintexts.Length; index++)
+ {
+ Assert.That(plaintexts[index], Is.EqualTo(BitConverter.GetBytes(index)));
+ }
+ }
+
+ [Test]
+ public void SealRejectsSequenceNumberExhaustion()
+ {
+ byte[] secret = CreateSecret(DtlsCipherSuite.TlsAes128GcmSha256);
+ using var writer = new DtlsRecordProtection(
+ CreateProfile(DtlsCipherSuite.TlsAes128GcmSha256),
+ secret,
+ epoch: 1,
+ initialWriteSequenceNumber: DtlsRecordProtection.MaximumRecordSequenceNumber);
+
+ Assert.That(writer.Seal([0x01]), Is.Not.Empty);
+ Assert.That(
+ () => writer.Seal([0x02]),
+ Throws.TypeOf()
+ .With.Message.Contains("sequence number exhausted"));
+ }
+
+ [Test]
+ public void ConstructorRejectsInitialSequenceNumberAboveEpochLimit()
+ {
+ byte[] secret = CreateSecret(DtlsCipherSuite.TlsAes128GcmSha256);
+ Assert.That(
+ () => new DtlsRecordProtection(
+ CreateProfile(DtlsCipherSuite.TlsAes128GcmSha256),
+ secret,
+ epoch: 1,
+ initialWriteSequenceNumber: DtlsRecordProtection.MaximumRecordSequenceNumber + 1),
+ Throws.TypeOf());
+ }
+
+ [Test]
+ public void DisposeIsIdempotent()
+ {
+ byte[] secret = CreateSecret(DtlsCipherSuite.TlsAes128GcmSha256);
+ var reader = new DtlsRecordProtection(
+ CreateProfile(DtlsCipherSuite.TlsAes128GcmSha256),
+ secret,
+ epoch: 1);
+ reader.Dispose();
+ Assert.That(() => reader.Dispose(), Throws.Nothing);
+ }
+
private static byte[] CreateSecret(DtlsCipherSuite cipherSuite)
{
int length = cipherSuite is DtlsCipherSuite.TlsAes256GcmSha384 or DtlsCipherSuite.TlsSha384Sha384 ? 48 : 32;
diff --git a/tests/Opc.Ua.PubSub.Udp.Tests/UdpDatagramTransportUnicastTests.cs b/tests/Opc.Ua.PubSub.Udp.Tests/UdpDatagramTransportUnicastTests.cs
index 3f205de4b9..e1aa0c8cf4 100644
--- a/tests/Opc.Ua.PubSub.Udp.Tests/UdpDatagramTransportUnicastTests.cs
+++ b/tests/Opc.Ua.PubSub.Udp.Tests/UdpDatagramTransportUnicastTests.cs
@@ -238,5 +238,79 @@ public async Task TransportPublishesIsConnected()
Assert.That(transport.Direction, Is.EqualTo(PubSubTransportDirection.SendReceive));
Assert.That(transport.Endpoint.Port, Is.EqualTo(port));
}
+
+ [Test]
+ public async Task SetAuthenticatedRemoteEndpointGuardsConnectedSendClientAsync()
+ {
+ int port;
+ try
+ {
+ port = UdpIntegrationTestHelpers.ReserveEphemeralPort(IPAddress.Loopback);
+ }
+ catch (SocketException ex)
+ {
+ Assert.Ignore($"Loopback UDP socket bind failed: {ex.Message}");
+ return;
+ }
+
+ string url = $"opc.udp://127.0.0.1:{port}";
+ UdpEndpoint endpoint = UdpEndpointParser.Parse(url);
+ UdpTransportOptions options = UdpIntegrationTestHelpers.LoopbackOptions();
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+
+ await using var receiver = new UdpDatagramTransport(
+ UdpIntegrationTestHelpers.NewConnection(url, "Subscriber"),
+ endpoint,
+ PubSubTransportDirection.Receive,
+ networkInterface: null,
+ telemetry,
+ TimeProvider.System,
+ options);
+ await using var sender = new UdpDatagramTransport(
+ UdpIntegrationTestHelpers.NewConnection(url, "Publisher"),
+ endpoint,
+ PubSubTransportDirection.Send,
+ networkInterface: null,
+ telemetry,
+ TimeProvider.System,
+ options);
+
+ try
+ {
+ await receiver.OpenAsync().ConfigureAwait(false);
+ await sender.OpenAsync().ConfigureAwait(false);
+ }
+ catch (SocketException ex)
+ {
+ Assert.Ignore($"Unicast loopback open failed: {ex.Message}");
+ return;
+ }
+
+ // A connected send client rejects a null pin and ignores an attempt
+ // to rebind to a different endpoint, so its socket cannot be
+ // redirected to an attacker-chosen destination.
+ Assert.That(
+ () => sender.SetAuthenticatedRemoteEndpoint(null!),
+ Throws.TypeOf());
+ sender.SetAuthenticatedRemoteEndpoint(new IPEndPoint(IPAddress.Loopback, port + 1));
+
+ // The send destination is unchanged: the payload still reaches the
+ // receiver bound to the original port.
+ byte[] payload = [0x10, 0x20, 0x30];
+ for (int attempt = 0; attempt < 5; attempt++)
+ {
+ await sender.SendAsync(payload).ConfigureAwait(false);
+ PubSubTransportFrame? frame = await UdpIntegrationTestHelpers.ReceiveOneAsync(
+ receiver,
+ TimeSpan.FromMilliseconds(500)).ConfigureAwait(false);
+ if (frame is not null)
+ {
+ Assert.That(frame.Value.Payload.ToArray(), Is.EqualTo(payload));
+ return;
+ }
+ }
+
+ Assert.Ignore("No unicast loopback frame received within retry budget; environment likely blocks UDP.");
+ }
}
}