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."); + } } }