diff --git a/docs/PubSub.md b/docs/PubSub.md index cbd330c345..d8f838acdb 100644 --- a/docs/PubSub.md +++ b/docs/PubSub.md @@ -95,17 +95,13 @@ DataSet #1 received (3 fields): BoolToggle=False, Int32=1, DateTime=09/28/2026 1 DataSet #2 received (3 fields): BoolToggle=True, Int32=2, DateTime=09/28/2026 12:18:51 ``` -The `udp-uadp` profile signs and encrypts the UADP messages with demo keys -that are built into the sample. A production deployment obtains its keys from -a [Security Key Service](#security-key-service-sks). Two warnings are expected: - -- `DataSetReader Reader 1 faulted on MessageReceiveTimeout (>00:00:05)` - appears when no matching message arrives for five seconds, for example - before the publisher starts. The reader resumes when messages arrive. -- `Dropping unsecured inbound frame ... requiring SignAndEncrypt` appears once. - The publisher announces its DataSet metadata without message security, and - the secured subscriber discards the announcement. It decodes the DataSets - with the metadata configured on its DataSetReader instead. +The `udp-uadp` profile signs and encrypts the UADP messages, including the +DataSet metadata announcement, with demo keys that are built into the sample. +A production deployment obtains its keys from a +[Security Key Service](#security-key-service-sks). +`DataSetReader Reader 1 faulted on MessageReceiveTimeout (>00:00:05)` can +appear when no matching message arrives for five seconds, for example before +the publisher starts. The reader resumes when messages arrive. The sample's default endpoint, `opc.udp://239.0.0.1:4840`, is a multicast address. On one computer, the subscriber can miss multicast messages because @@ -287,7 +283,11 @@ The publisher-side `MetaDataPublisher` ([§6.2.2.5](https://reference.opcfoundat emits a retained `JsonMetaDataMessage` / `UadpDiscoveryResponseMessage` on the well-known `ua-metadata` topic at startup and after each configuration version bump; subscribers cache it before the first -KeyFrame arrives. +KeyFrame arrives. A connection configured for message security applies its +SecurityMode to the UADP announcement, with the same keys as its +DataSetMessages: `Sign` lets subscribers verify the announcement, and +`SignAndEncrypt` also hides the DataSet layout from applications that do not +hold the keys. A subscriber also registers the `DataSetMetaData` configured on each DataSetReader whose filter names an exact PublisherId, WriterGroupId, and @@ -1225,6 +1225,12 @@ subscribers that need to find publishers and bind to metadata at runtime. duplicate probes are suppressed, and identical responses are throttled. - MQTT publishes retained discovery messages on the standard status, connection, application, endpoint, and metadata topics. +- A UADP connection configured for message security applies its SecurityMode + to its discovery requests, responses, and announcements, with the keys of + its SecurityGroup: they are signed, and with `SignAndEncrypt` also + encrypted. A secured connection therefore discovers only applications that + hold the same keys. The MQTT last-will status message is the exception: the + broker publishes it later, so it is sent unsecured. ```csharp PubSubDiscoveryResult result = await application.RequestDiscoveryAsync( diff --git a/src/Opc.Ua.PubSub/Application/MetaDataPublisher.cs b/src/Opc.Ua.PubSub/Application/MetaDataPublisher.cs index 9c162a9c3f..07c81830c8 100644 --- a/src/Opc.Ua.PubSub/Application/MetaDataPublisher.cs +++ b/src/Opc.Ua.PubSub/Application/MetaDataPublisher.cs @@ -75,6 +75,13 @@ namespace Opc.Ua.PubSub.Application /// . /// /// + /// Announcements go through the connection's send pipeline, so a + /// connection configured for message security applies its + /// SecurityMode to them with the same keys as its DataSetMessages + /// (signing, and with SignAndEncrypt also encryption), and refuses + /// to send them when it cannot secure them. + /// + /// /// Lifetime is owned by : started /// after EnableConnectionsAsync returns, disposed before /// the connections are torn down. @@ -84,9 +91,6 @@ internal sealed class MetaDataPublisher : IAsyncDisposable { private readonly PubSubApplication m_application; private readonly IDataSetMetaDataRegistry m_registry; - private readonly IReadOnlyDictionary m_encoders; - private readonly IPubSubDiagnostics m_diagnostics; - private readonly ITelemetryContext m_telemetry; private readonly BackgroundTaskScope m_backgroundWork; private readonly TimeProvider m_timeProvider; private readonly ILogger m_logger; @@ -106,17 +110,11 @@ internal sealed class MetaDataPublisher : IAsyncDisposable /// the matching transport per writer group. /// /// Shared metadata registry. - /// - /// Network-message encoders keyed by transport profile URI. - /// - /// Diagnostics sink. /// Telemetry context. /// Clock used to stamp MessageIds. public MetaDataPublisher( PubSubApplication application, IDataSetMetaDataRegistry metaDataRegistry, - IReadOnlyDictionary encoders, - IPubSubDiagnostics diagnostics, ITelemetryContext telemetry, TimeProvider timeProvider) { @@ -128,14 +126,6 @@ public MetaDataPublisher( { throw new ArgumentNullException(nameof(metaDataRegistry)); } - if (encoders is null) - { - throw new ArgumentNullException(nameof(encoders)); - } - if (diagnostics is null) - { - throw new ArgumentNullException(nameof(diagnostics)); - } if (telemetry is null) { throw new ArgumentNullException(nameof(telemetry)); @@ -146,9 +136,6 @@ public MetaDataPublisher( } m_application = application; m_registry = metaDataRegistry; - m_encoders = encoders; - m_diagnostics = diagnostics; - m_telemetry = telemetry; m_backgroundWork = new BackgroundTaskScope(nameof(MetaDataPublisher), telemetry); m_timeProvider = timeProvider; m_logger = telemetry.CreateLogger(); @@ -497,22 +484,16 @@ private async ValueTask PublishMetaDataAsync( { return; } - string profile = connection.TransportProfileUri; - string family = TransportProfileFamily(profile); Uuid classId = metaData.DataSetClassId == Guid.Empty ? Uuid.Empty : new Uuid(metaData.DataSetClassId); - ReadOnlyMemory payload; - string? topic; - if (string.Equals(family, "Json", StringComparison.Ordinal)) + PubSubNetworkMessage message; + if (string.Equals( + TransportProfileFamily(connection.TransportProfileUri), + "Json", + StringComparison.Ordinal)) { - if (!TryResolveEncoder(profile, family, out INetworkMessageEncoder? encoder) || - encoder is null) - { - m_logger.NoJsonEncoderRegistered(profile); - return; - } - var message = new JsonMetaDataMessage + message = new JsonMetaDataMessage { MessageId = NewMessageId(), PublisherId = connection.PublisherId, @@ -530,22 +511,10 @@ private async ValueTask PublishMetaDataAsync( MetaData = metaData, MetaDataPayload = metaData }; - var context = new PubSubNetworkMessageContext( - ServiceMessageContext.CreateEmpty(m_telemetry), - m_registry, - m_diagnostics, - m_timeProvider); - payload = await encoder.EncodeAsync(message, context, cancellationToken) - .ConfigureAwait(false); - topic = ResolveMetaDataTopic( - transport, - connection.PublisherId, - writerGroup.WriterGroupId, - writer.DataSetWriterId); } else { - var message = new UadpDiscoveryResponseMessage + message = new UadpDiscoveryResponseMessage { PublisherId = connection.PublisherId, WriterGroupId = writerGroup.WriterGroupId, @@ -556,44 +525,17 @@ private async ValueTask PublishMetaDataAsync( SequenceNumber = NewSequenceNumber(), StatusCode = StatusCodes.Good }; - var context = new PubSubNetworkMessageContext( - ServiceMessageContext.CreateEmpty(m_telemetry), - m_registry, - m_diagnostics, - m_timeProvider); - payload = UadpDiscoveryCoder.Encode(message, context); - topic = ResolveMetaDataTopic( - transport, - connection.PublisherId, - writerGroup.WriterGroupId, - writer.DataSetWriterId); } + string? topic = ResolveMetaDataTopic( + transport, + connection.PublisherId, + writerGroup.WriterGroupId, + writer.DataSetWriterId); - await transport.SendAsync(payload, topic, cancellationToken).ConfigureAwait(false); - } - - private bool TryResolveEncoder( - string profile, - string family, - out INetworkMessageEncoder? encoder) - { - if (m_encoders.TryGetValue(profile, out encoder)) - { - return true; - } - foreach (KeyValuePair entry in m_encoders) - { - if (string.Equals( - TransportProfileFamily(entry.Key), - family, - StringComparison.Ordinal)) - { - encoder = entry.Value; - return true; - } - } - encoder = null; - return false; + // The connection applies its message security, so a secured + // connection never sends the announcement unsecured. + await connection.SendMetaDataAnnouncementAsync(message, topic, cancellationToken) + .ConfigureAwait(false); } private static string? ResolveMetaDataTopic( @@ -681,10 +623,6 @@ public static partial void FailedToPublishMetadataChange( Exception exception, ushort writer, ushort group); - - [LoggerMessage(EventId = PubSubEventIds.MetaDataPublisher + 2, Level = LogLevel.Debug, - Message = "No JSON encoder registered for {Profile}; metadata publish skipped.")] - public static partial void NoJsonEncoderRegistered(this ILogger logger, string? profile); } } diff --git a/src/Opc.Ua.PubSub/Application/PubSubApplication.cs b/src/Opc.Ua.PubSub/Application/PubSubApplication.cs index 1b8a3f3ee7..f31bef344e 100644 --- a/src/Opc.Ua.PubSub/Application/PubSubApplication.cs +++ b/src/Opc.Ua.PubSub/Application/PubSubApplication.cs @@ -662,8 +662,6 @@ public async ValueTask StartAsync(CancellationToken cancellationToken = default) var metaDataPublisher = new MetaDataPublisher( this, MetaDataRegistry, - m_encoderMap, - m_aggregatingDiagnostics, m_telemetry, m_timeProvider); try diff --git a/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs b/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs index 2ad26f42e0..e96b53aec4 100644 --- a/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs +++ b/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs @@ -1866,6 +1866,24 @@ private async ValueTask SendDiscoveryResponseAsync( { return; } + try + { + await SendDiscoveryMessageAsync(response, topic, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // A discovery message that cannot be secured or sent, for example while + // no security key is available, must neither fail the start of the + // connection nor end its receive loop. + m_logger.DiscoveryMessageNotSent(ex, response.DiscoveryType, Name); + } + } + + private async ValueTask SendDiscoveryMessageAsync( + UadpDiscoveryResponseMessage response, + string? topic, + CancellationToken cancellationToken) + { PubSubNetworkMessage networkMessage = ConvertDiscoveryMessageForTransport(response); INetworkMessageEncoder? encoder = ResolveEncoder(); if (ShouldUseDiscoveryAnnouncementDestination( @@ -1873,11 +1891,21 @@ private async ValueTask SendDiscoveryResponseAsync( out IPubSubDiscoveryAnnouncementTransport? announcementTransport) && encoder is not null) { - ReadOnlyMemory payload = await EncodeNetworkMessageAsync( + var context = new PubSubNetworkMessageContext( + ServiceMessageContext.CreateEmpty(m_telemetry), + m_metaDataRegistry, + m_diagnostics, + m_timeProvider); + ReadOnlyMemory? payload = await EncodeOutboundAsync( networkMessage, encoder, + context, cancellationToken).ConfigureAwait(false); - await announcementTransport!.SendDiscoveryAnnouncementAsync(payload, cancellationToken) + if (payload is null) + { + return; + } + await announcementTransport!.SendDiscoveryAnnouncementAsync(payload.Value, cancellationToken) .ConfigureAwait(false); return; } @@ -2454,6 +2482,23 @@ private ValueTask SendNetworkMessageAsync( return SendNetworkMessageAsync(networkMessage, topic, writerGroup: null, cancellationToken); } + /// + /// Sends a DataSetMetaData announcement through the connection's send + /// pipeline, so a connection configured for message security secures the + /// announcement with its SecurityMode, like its DataSetMessages, and never + /// sends it unsecured. + /// + /// UADP discovery response or JSON metadata message. + /// Broker topic, or for datagram transports. + /// Cancellation token. + internal ValueTask SendMetaDataAnnouncementAsync( + PubSubNetworkMessage announcement, + string? topic, + CancellationToken cancellationToken) + { + return SendNetworkMessageAsync(announcement, topic, writerGroup: null, cancellationToken); + } + private async ValueTask SendNetworkMessageAsync( PubSubNetworkMessage networkMessage, string? topic, @@ -2481,48 +2526,79 @@ private async ValueTask SendNetworkMessageAsync( m_diagnostics, m_timeProvider); - ReadOnlyMemory payload; - if (m_securityWrapper is not null && - networkMessage is UadpNetworkMessage uadp) + ReadOnlyMemory? encoded = await EncodeOutboundAsync( + networkMessage, + encoder, + context, + cancellationToken).ConfigureAwait(false); + if (encoded is null) + { + return; + } + ReadOnlyMemory payload = encoded.Value; + + if (m_maxNetworkMessageSize > 0 && + payload.Length > m_maxNetworkMessageSize && + networkMessage is UadpNetworkMessage uadpForChunk) { - payload = await EncodeAndWrapUadpAsync(uadp, context, cancellationToken) + await SendChunkedAsync(transport, uadpForChunk, context, writerGroup, cancellationToken) .ConfigureAwait(false); + return; } - else if (RequiresInboundSecurity || + + await transport.SendAsync(payload, topic, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Encodes a NetworkMessage for sending and applies the message security of + /// the connection. Returns , and the message must not be + /// sent, when the connection requires message security that the message + /// cannot carry. + /// + private async ValueTask?> EncodeOutboundAsync( + PubSubNetworkMessage networkMessage, + INetworkMessageEncoder encoder, + PubSubNetworkMessageContext context, + CancellationToken cancellationToken) + { + if (m_securityWrapper is not null && IsSecurableUadpMessage(networkMessage)) + { + return await EncodeAndWrapUadpAsync(networkMessage, context, cancellationToken) + .ConfigureAwait(false); + } + if (RequiresInboundSecurity || (m_securityWrapper is not null && m_requiredSecurityMode is MessageSecurityMode.Sign or MessageSecurityMode.SignAndEncrypt)) { - // Fail-closed: never emit plaintext for a secured group. - // This path is only reachable for non-UADP messages, which - // the UADP security wrapper cannot protect. + // Fail-closed: never emit plaintext for a secured group. This path is + // reached for messages that the UADP security wrapper cannot protect. m_diagnostics.Increment(PubSubDiagnosticsCounterKind.EncryptionErrors); m_diagnostics.RecordError( StatusCodes.BadSecurityModeRejected, "Refusing to publish an unsecured NetworkMessage on a connection " + "configured for message security."); m_logger.DroppingOutboundMessage(Name, m_requiredSecurityMode); - return; - } - else - { - payload = await encoder.EncodeAsync( - networkMessage, - context, - cancellationToken).ConfigureAwait(false); + return null; } + return await encoder.EncodeAsync(networkMessage, context, cancellationToken) + .ConfigureAwait(false); + } - if (m_maxNetworkMessageSize > 0 && - payload.Length > m_maxNetworkMessageSize && - networkMessage is UadpNetworkMessage uadpForChunk) + /// + /// Returns true for the NetworkMessages that the UADP security wrapper + /// protects: UADP data messages, and the discovery requests and responses + /// of a connection that uses the UADP encoding. + /// + private bool IsSecurableUadpMessage(PubSubNetworkMessage networkMessage) + { + if (networkMessage is UadpNetworkMessage) { - await SendChunkedAsync(transport, uadpForChunk, context, writerGroup, cancellationToken) - .ConfigureAwait(false); - return; + return true; } - - await transport.SendAsync(payload, topic, cancellationToken) - .ConfigureAwait(false); + return networkMessage is UadpDiscoveryRequestMessage or UadpDiscoveryResponseMessage && + TransportProfileFamily(TransportProfileUri) == "Uadp"; } private ValueTask> EncodeNetworkMessageAsync( @@ -2685,17 +2761,18 @@ await transport.SendAsync(frame, topic, cancellationToken) } private async ValueTask> EncodeAndWrapUadpAsync( - UadpNetworkMessage message, + PubSubNetworkMessage message, PubSubNetworkMessageContext context, CancellationToken cancellationToken) { + ReadOnlyMemory wrapped; try { ReadOnlyMemory encoded = UadpEncoder.EncodeWithSecurityBoundary( message, context, out int payloadOffset); ReadOnlyMemory prefix = encoded[..payloadOffset]; ReadOnlyMemory inner = encoded[payloadOffset..]; - return await m_securityWrapper! + wrapped = await m_securityWrapper! .WrapAsync(prefix, inner, m_securityWrapOptions, cancellationToken) .ConfigureAwait(false); } @@ -2712,6 +2789,17 @@ private async ValueTask> EncodeAndWrapUadpAsync( m_logger.UadpSecurityWrapFailed(ex); throw; } + + // The encoder counts the messages it encodes; the secured encoding bypasses + // it, so count the secured message in the same way. + context.Diagnostics.Increment(PubSubDiagnosticsCounterKind.SentNetworkMessages); + if (message is UadpNetworkMessage { DataSetMessages.Count: > 0 } data) + { + context.Diagnostics.Increment( + PubSubDiagnosticsCounterKind.SentDataSetMessages, + data.DataSetMessages.Count); + } + return wrapped; } private INetworkMessageEncoder? ResolveEncoder() @@ -3317,6 +3405,14 @@ public static partial void DroppingOutboundMessage( [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); + + [LoggerMessage(EventId = PubSubEventIds.PubSubConnection + 22, Level = LogLevel.Warning, + Message = "Could not send the {DiscoveryType} discovery message on connection '{Connection}'.")] + public static partial void DiscoveryMessageNotSent( + this ILogger logger, + Exception exception, + UadpDiscoveryType discoveryType, + string connection); } } diff --git a/src/Opc.Ua.PubSub/Encoding/Uadp/UadpDiscoveryCoder.cs b/src/Opc.Ua.PubSub/Encoding/Uadp/UadpDiscoveryCoder.cs index 688c9ef345..caa0a0c986 100644 --- a/src/Opc.Ua.PubSub/Encoding/Uadp/UadpDiscoveryCoder.cs +++ b/src/Opc.Ua.PubSub/Encoding/Uadp/UadpDiscoveryCoder.cs @@ -119,6 +119,31 @@ public static class UadpDiscoveryCoder public static byte[] Encode( PubSubNetworkMessage message, PubSubNetworkMessageContext context) + { + return Encode(message, context, securityEnabled: false, out _); + } + + /// + /// Encodes a discovery NetworkMessage and reports the byte offset + /// at which the security wrapper inserts the SecurityHeader: the + /// discovery header and payload that follow the PublisherId are + /// signed and encrypted (Part 14 §7.2.4.6.3). + /// + /// Source message; must be a + /// or + /// . + /// Network message context. + /// Sets the SecurityHeader bit in + /// ExtendedFlags1. + /// Boundary between the outer prefix + /// and the inner payload. + /// + /// + internal static byte[] Encode( + PubSubNetworkMessage message, + PubSubNetworkMessageContext context, + bool securityEnabled, + out int payloadOffset) { if (message is null) { @@ -131,9 +156,9 @@ public static byte[] Encode( return message switch { UadpDiscoveryRequestMessage request => - EncodeRequest(request, context), + EncodeRequest(request, context, securityEnabled, out payloadOffset), UadpDiscoveryResponseMessage response => - EncodeResponse(response, context), + EncodeResponse(response, context, securityEnabled, out payloadOffset), _ => throw new InvalidOperationException( "Discovery encoding requires a UadpDiscoveryRequestMessage " + "or UadpDiscoveryResponseMessage instance.") @@ -177,13 +202,17 @@ public static byte[] Encode( private static byte[] EncodeRequest( UadpDiscoveryRequestMessage message, - PubSubNetworkMessageContext context) + PubSubNetworkMessageContext context, + bool securityEnabled, + out int payloadOffset) { byte[] buffer = new byte[1024]; var writer = new UadpBinaryWriter(buffer, 0, buffer.Length); UadpDiscoveryWire.WriteCommonHeader( ref writer, message, - ExtendedFlags2EncodingMask.NetworkMessageWithDiscoveryRequest); + ExtendedFlags2EncodingMask.NetworkMessageWithDiscoveryRequest, + securityEnabled); + payloadOffset = writer.Position; writer.WriteByte((byte)message.DiscoveryType); writer.WriteUInt32Le((uint)message.DataSetWriterIds.Count); @@ -201,13 +230,17 @@ private static byte[] EncodeRequest( private static byte[] EncodeResponse( UadpDiscoveryResponseMessage message, - PubSubNetworkMessageContext context) + PubSubNetworkMessageContext context, + bool securityEnabled, + out int payloadOffset) { byte[] buffer = new byte[8192]; var writer = new UadpBinaryWriter(buffer, 0, buffer.Length); UadpDiscoveryWire.WriteCommonHeader( ref writer, message, - ExtendedFlags2EncodingMask.NetworkMessageWithDiscoveryResponse); + ExtendedFlags2EncodingMask.NetworkMessageWithDiscoveryResponse, + securityEnabled); + payloadOffset = writer.Position; writer.WriteByte((byte)message.DiscoveryType); writer.WriteUInt16Le(message.SequenceNumber); @@ -790,21 +823,25 @@ internal static class UadpDiscoveryWire public static void WriteCommonHeader( ref UadpBinaryWriter writer, UadpDiscoveryRequestMessage message, - ExtendedFlags2EncodingMask discoveryBit) + ExtendedFlags2EncodingMask discoveryBit, + bool securityEnabled) { WriteCommonHeader( ref writer, message.UadpVersion, message.PublisherId, - message.DataSetClassId, discoveryBit); + message.DataSetClassId, discoveryBit, securityEnabled, + payloadHeaderEnabled: false, writerGroupId: null); } public static void WriteCommonHeader( ref UadpBinaryWriter writer, UadpDiscoveryResponseMessage message, - ExtendedFlags2EncodingMask discoveryBit) + ExtendedFlags2EncodingMask discoveryBit, + bool securityEnabled) { WriteCommonHeader( ref writer, message.UadpVersion, message.PublisherId, - message.DataSetClassId, discoveryBit); + message.DataSetClassId, discoveryBit, securityEnabled, + payloadHeaderEnabled: false, writerGroupId: null); } public static void WriteCommonHeader( @@ -822,18 +859,6 @@ public static void WriteCommonHeader( securityEnabled, payloadHeaderEnabled, writerGroupId); } - private static void WriteCommonHeader( - ref UadpBinaryWriter writer, - byte uadpVersion, - PublisherId publisherId, - Uuid dataSetClassId, - ExtendedFlags2EncodingMask discoveryBit) - { - WriteCommonHeaderCore( - ref writer, uadpVersion, publisherId, dataSetClassId, discoveryBit, - securityEnabled: false, payloadHeaderEnabled: false, writerGroupId: null); - } - private static void WriteCommonHeaderCore( ref UadpBinaryWriter writer, byte uadpVersion, diff --git a/src/Opc.Ua.PubSub/Encoding/Uadp/UadpEncoder.cs b/src/Opc.Ua.PubSub/Encoding/Uadp/UadpEncoder.cs index d2f5ab9541..1950ae216f 100644 --- a/src/Opc.Ua.PubSub/Encoding/Uadp/UadpEncoder.cs +++ b/src/Opc.Ua.PubSub/Encoding/Uadp/UadpEncoder.cs @@ -147,12 +147,12 @@ public static ReadOnlyMemory EncodeWithSecurityBoundary( } /// - /// Encodes a UADP data or action NetworkMessage with the + /// Encodes a UADP data, action or discovery NetworkMessage with the /// ExtendedFlags1.SecurityEnabled bit set and reports the /// byte offset at which the security wrapper must insert the /// SecurityHeader. /// - /// UADP data or action message. + /// UADP data, action or discovery message. /// Network message context. /// Boundary between outer prefix and inner payload. /// @@ -171,8 +171,14 @@ public static ReadOnlyMemory EncodeWithSecurityBoundary( return UadpActionCoder.Encode( networkMessage, context, securityEnabled: true, out payloadOffset); } + if (networkMessage is UadpDiscoveryRequestMessage + or UadpDiscoveryResponseMessage) + { + return UadpDiscoveryCoder.Encode( + networkMessage, context, securityEnabled: true, out payloadOffset); + } throw new ArgumentException( - "Security wrapping is supported for UADP data and action messages.", + "Security wrapping is supported for UADP data, action and discovery messages.", nameof(networkMessage)); } diff --git a/tests/Opc.Ua.PubSub.Tests/Application/MetaDataPublisherTests.cs b/tests/Opc.Ua.PubSub.Tests/Application/MetaDataPublisherTests.cs index b1b1f7fed5..72abce97ca 100644 --- a/tests/Opc.Ua.PubSub.Tests/Application/MetaDataPublisherTests.cs +++ b/tests/Opc.Ua.PubSub.Tests/Application/MetaDataPublisherTests.cs @@ -42,6 +42,9 @@ using Opc.Ua.PubSub.Encoding.Json; using Opc.Ua.PubSub.Encoding.Uadp; using Opc.Ua.PubSub.MetaData; +using Opc.Ua.PubSub.Security; +using Opc.Ua.PubSub.Security.Policies; +using Opc.Ua.PubSub.Tests.Security; using Opc.Ua.PubSub.Transports; using Opc.Ua.Tests; @@ -73,6 +76,8 @@ public class MetaDataPublisherTests private const ushort PublisherIdValue = 17; private const ushort WriterGroupIdValue = 7; private const ushort DataSetWriterIdValue = 42; + private const string SecurityGroupIdValue = "metadata-group"; + private const uint SecurityTokenIdValue = 1; [Test] public async Task OnStartup_PublishesMetaData_ToMatchingTransport() @@ -166,6 +171,92 @@ await WaitUntilAsync( Assert.That(response.DataSetWriterId, Is.EqualTo(DataSetWriterIdValue)); } + /// + /// The announcement of a WriterGroup configured for SignAndEncrypt is + /// secured with the connection's keys like its DataSetMessages, so a + /// subscriber holding the keys can verify and decrypt it and nobody + /// else can read the DataSet layout. + /// + [Test] + public async Task UadpPathSecuresTheAnnouncementOfASecuredWriterGroupAsync() + { + using PubSubSecurityKeyRing publisherRing = NewKeyRing(); + using PubSubSecurityKeyRing subscriberRing = NewKeyRing(); + var factory = new RecordingTransportFactory(UadpProfile, supportsTopics: false); + await using IPubSubApplication app = BuildApp( + UadpProfile, + factory, + securityKeyProvider: new StaticSecurityKeyProvider(SecurityGroupIdValue, publisherRing)); + + await app.StartAsync(CancellationToken.None).ConfigureAwait(false); + await WaitUntilAsync( + () => factory.Transport is { } t && t.Sends.Count >= 1, + TimeSpan.FromSeconds(2)).ConfigureAwait(false); + byte[] payload = factory.Transport!.Sends[0].Payload.ToArray(); + + Assert.That( + UadpDecoder.TryReadOuterPrefix(payload, out int prefixLength, out bool securityEnabled, out _, out _), + Is.True); + Assert.That(securityEnabled, Is.True, "The announcement must carry a SecurityHeader."); + Assert.That( + payload.AsSpan().IndexOf(System.Text.Encoding.UTF8.GetBytes("pds-1")), + Is.EqualTo(-1), + "The DataSet name must not appear in cleartext."); + + var window = new SecurityTokenWindow(); + window.RegisterToken(SecurityTokenIdValue); + using var nonceProvider = new RandomNonceProvider(PublisherId.FromUInt16(PublisherIdValue)); + var subscriber = new UadpSecurityWrapper( + PubSubAes256CtrPolicy.Instance, + new StaticSecurityKeyProvider(SecurityGroupIdValue, subscriberRing), + nonceProvider, + window, + NUnitTelemetryContext.Create()); + UadpSecurityWrapper.UnwrapResult unwrapped = await subscriber + .TryUnwrapAsync(payload.AsMemory(0, prefixLength), payload.AsMemory(prefixLength)) + .ConfigureAwait(false); + Assert.That(unwrapped.IsSuccess, Is.True, unwrapped.Reason); + + byte[] cleartext = [.. payload.AsSpan(0, prefixLength), .. unwrapped.InnerPayload!.Value.Span]; + PubSubNetworkMessage? decoded = UadpDecoder.Decode(cleartext, NewDecodeContext()); + Assert.That(decoded, Is.InstanceOf()); + var response = (UadpDiscoveryResponseMessage)decoded!; + Assert.That(response.DiscoveryType, Is.EqualTo(UadpDiscoveryType.DataSetMetaData)); + Assert.That(response.DataSetMetaData?.Name, Is.EqualTo("pds-1")); + } + + /// + /// A secured NetworkMessage is encoded at the security boundary, outside the + /// encoder that counts the messages it encodes, and must still be counted as + /// sent. + /// + [Test] + public async Task SecuredNetworkMessagesAreCountedAsSentAsync() + { + using PubSubSecurityKeyRing ring = NewKeyRing(); + var factory = new RecordingTransportFactory(UadpProfile, supportsTopics: false); + await using IPubSubApplication app = BuildApp( + UadpProfile, + factory, + securityKeyProvider: new StaticSecurityKeyProvider(SecurityGroupIdValue, ring)); + + await app.StartAsync(CancellationToken.None).ConfigureAwait(false); + await WaitUntilAsync( + () => factory.Transport is { } t && t.Sends.Count >= 1, + TimeSpan.FromSeconds(2)).ConfigureAwait(false); + + // Each message is counted before it reaches the transport, so the counter + // read after the sends is at least the number of sends. + int sends; + lock (factory.Transport!.Sends) + { + sends = factory.Transport.Sends.Count; + } + Assert.That( + app.Diagnostics.Read(PubSubDiagnosticsCounterKind.SentNetworkMessages), + Is.GreaterThanOrEqualTo(sends)); + } + [Test] public async Task DisposeAsync_UnsubscribesFromRegistry() { @@ -341,7 +432,8 @@ private static IPubSubApplication BuildApp( RecordingTransportFactory factory, IPublishedDataSetSource? source = null, INetworkMessageEncoder? encoder = null, - ushort? secondWriterId = null) + ushort? secondWriterId = null, + IPubSubSecurityKeyProvider? securityKeyProvider = null) { string addressUrl = transportProfileUri == JsonMqttProfile ? "mqtt://localhost:1883" @@ -392,6 +484,16 @@ secondWriterId is null } }) }; + if (securityKeyProvider is not null) + { + WriterGroupDataType writerGroup = connection.WriterGroups[0]; + writerGroup.SecurityMode = MessageSecurityMode.SignAndEncrypt; + writerGroup.SecurityGroupId = SecurityGroupIdValue; + writerGroup.SecurityKeyServices = new ArrayOf(new[] + { + new EndpointDescription { EndpointUrl = "opc.tcp://localhost:4840/SecurityKeyService" } + }); + } var pds = new PublishedDataSetDataType { Name = "pds-1", @@ -426,9 +528,23 @@ secondWriterId is null { builder.AddEncoder(encoder); } + if (securityKeyProvider is not null) + { + builder.AddSecurityKeyProvider(securityKeyProvider); + } return builder.Build(); } + private static PubSubSecurityKeyRing NewKeyRing() + { + var ring = new PubSubSecurityKeyRing(SecurityGroupIdValue); + ring.SetCurrent(TestSecurityKeyFactory.Create( + SecurityTokenIdValue, + PubSubAes256CtrPolicy.Instance.SigningKeyLength, + PubSubAes256CtrPolicy.Instance.EncryptingKeyLength)); + return ring; + } + private static DataSetMetaDataKey NewKey() { return new DataSetMetaDataKey( diff --git a/tests/Opc.Ua.PubSub.Tests/Application/PubSubDiscoveryTests.cs b/tests/Opc.Ua.PubSub.Tests/Application/PubSubDiscoveryTests.cs index 7023b489d1..a446f143fb 100644 --- a/tests/Opc.Ua.PubSub.Tests/Application/PubSubDiscoveryTests.cs +++ b/tests/Opc.Ua.PubSub.Tests/Application/PubSubDiscoveryTests.cs @@ -39,6 +39,9 @@ using Opc.Ua.PubSub.Diagnostics; using Opc.Ua.PubSub.Encoding; using Opc.Ua.PubSub.Encoding.Uadp; +using Opc.Ua.PubSub.Security; +using Opc.Ua.PubSub.Security.Policies; +using Opc.Ua.PubSub.Tests.Security; using Opc.Ua.PubSub.Transports; using Opc.Ua.PubSub.Udp; using Opc.Ua.Tests; @@ -56,6 +59,7 @@ public class PubSubDiscoveryTests private const ushort WriterGroupIdValue = 7; private const ushort DataSetWriterIdValue = 42; private const string PublishedDataSetName = "pds-1"; + private const string SecurityGroupIdValue = "discovery-group"; [Test] public async Task RequestDiscoveryAsyncEncodesRequestAndCollectsResponse() @@ -176,6 +180,144 @@ public async Task UdpLoopbackDiscoveryPublisherAnswersSubscriberRequests() Assert.That(endpoints.PublisherEndpoints[0].EndpointUrl, Is.EqualTo(url)); } + /// + /// A subscriber and a publisher whose connections are secured with the same + /// SecurityGroup exchange discovery requests, responses, and the publisher's + /// transport-specific announcements, and no frame leaves either connection + /// without a SecurityHeader. + /// + [Test] + [TestSpec("7.2.4.6.3")] + public async Task SecuredSubscriberDiscoversSecuredPublisherWithoutPlaintextFramesAsync() + { + const string url = "opc.udp://239.0.0.1:4840"; + using PubSubSecurityKeyRing publisherRing = NewKeyRing(); + using PubSubSecurityKeyRing subscriberRing = NewKeyRing(); + var bus = new LoopbackBus(); + await using IPubSubApplication publisher = BuildPublisherApp( + url, + new LoopbackTransportFactory(bus), + new StaticSecurityKeyProvider(SecurityGroupIdValue, publisherRing)); + await using IPubSubApplication subscriber = BuildSubscriberApp( + url, + new LoopbackTransportFactory(bus), + new StaticSecurityKeyProvider(SecurityGroupIdValue, subscriberRing)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await publisher.StartAsync(cts.Token).ConfigureAwait(false); + await subscriber.StartAsync(cts.Token).ConfigureAwait(false); + + // Each request collects responses for its whole timeout, so the endpoints + // request is sent after the publisher's startup announcement of its + // endpoints has left the 500 ms duplicate-response window. + PubSubDiscoveryResult metaData = await subscriber.RequestDiscoveryAsync( + new PubSubDiscoveryRequest + { + DiscoveryType = UadpDiscoveryType.DataSetMetaData, + DataSetWriterIds = [DataSetWriterIdValue] + }, + TimeSpan.FromMilliseconds(600), + cts.Token).ConfigureAwait(false); + PubSubDiscoveryResult writerConfiguration = await subscriber.RequestDiscoveryAsync( + new PubSubDiscoveryRequest + { + DiscoveryType = UadpDiscoveryType.DataSetWriterConfiguration, + DataSetWriterIds = [DataSetWriterIdValue] + }, + TimeSpan.FromMilliseconds(600), + cts.Token).ConfigureAwait(false); + PubSubDiscoveryResult endpoints = await subscriber.RequestDiscoveryAsync( + new PubSubDiscoveryRequest + { + DiscoveryType = UadpDiscoveryType.PublisherEndpoints + }, + TimeSpan.FromMilliseconds(600), + cts.Token).ConfigureAwait(false); + + Assert.That(metaData.DataSetMetaDataEntries, Has.Count.EqualTo(1)); + Assert.That( + metaData.DataSetMetaDataEntries[0].DataSetMetaData?.Name, + Is.EqualTo(PublishedDataSetName)); + Assert.That(writerConfiguration.WriterConfigurations, Has.Count.EqualTo(1)); + Assert.That(endpoints.PublisherEndpoints, Is.Not.Empty); + Assert.That(bus.AnnouncementCount, Is.GreaterThan(0), + "The publisher sent no transport-specific discovery announcement."); + Assert.That(bus.PlaintextFrames, Is.Empty, + "Every frame of a secured connection must carry a SecurityHeader."); + } + + /// + /// Until the key provider has a current key, as before a Security Key Service + /// delivers the first key, the startup discovery announcements cannot be + /// secured. They are dropped, and the application still starts. + /// + [Test] + public async Task StartupAnnouncementsWithoutACurrentKeyDoNotFailTheStartAsync() + { + using PubSubSecurityKeyRing ring = NewKeyRing(); + var keys = new InterruptibleKeyProvider(new StaticSecurityKeyProvider(SecurityGroupIdValue, ring)) + { + IsCurrentKeyAvailable = false + }; + var bus = new LoopbackBus(); + await using IPubSubApplication publisher = BuildPublisherApp( + "opc.udp://239.0.0.1:4840", + new LoopbackTransportFactory(bus), + keys); + + await publisher.StartAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.That(publisher.State.State, Is.EqualTo(PubSubState.Operational)); + Assert.That(bus.PlaintextFrames, Is.Empty); + } + + /// + /// A discovery response that cannot be secured is dropped without ending the + /// receive loop of the publisher, which answers the next request. + /// + [Test] + public async Task ReceiveLoopSurvivesADiscoveryResponseThatCannotBeSecuredAsync() + { + const string url = "opc.udp://239.0.0.1:4840"; + using PubSubSecurityKeyRing publisherRing = NewKeyRing(); + using PubSubSecurityKeyRing subscriberRing = NewKeyRing(); + var publisherKeys = new InterruptibleKeyProvider( + new StaticSecurityKeyProvider(SecurityGroupIdValue, publisherRing)); + var bus = new LoopbackBus(); + await using IPubSubApplication publisher = BuildPublisherApp( + url, + new LoopbackTransportFactory(bus), + publisherKeys); + await using IPubSubApplication subscriber = BuildSubscriberApp( + url, + new LoopbackTransportFactory(bus), + new StaticSecurityKeyProvider(SecurityGroupIdValue, subscriberRing)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await publisher.StartAsync(cts.Token).ConfigureAwait(false); + await subscriber.StartAsync(cts.Token).ConfigureAwait(false); + + publisherKeys.IsCurrentKeyAvailable = false; + PubSubDiscoveryResult unanswered = await subscriber.RequestDiscoveryAsync( + new PubSubDiscoveryRequest + { + DiscoveryType = UadpDiscoveryType.DataSetMetaData, + DataSetWriterIds = [DataSetWriterIdValue] + }, + TimeSpan.FromMilliseconds(300), + cts.Token).ConfigureAwait(false); + publisherKeys.IsCurrentKeyAvailable = true; + PubSubDiscoveryResult answered = await subscriber.RequestDiscoveryAsync( + new PubSubDiscoveryRequest + { + DiscoveryType = UadpDiscoveryType.DataSetWriterConfiguration, + DataSetWriterIds = [DataSetWriterIdValue] + }, + TimeSpan.FromMilliseconds(300), + cts.Token).ConfigureAwait(false); + + Assert.That(unanswered.DataSetMetaDataEntries, Is.Empty); + Assert.That(answered.WriterConfigurations, Has.Count.EqualTo(1)); + } + private static IPubSubApplication BuildDiscoveryOnlyApp(IPubSubTransportFactory factory) { return new PubSubApplicationBuilder(NUnitTelemetryContext.Create()) @@ -203,10 +345,35 @@ private static IPubSubApplication BuildDiscoveryOnlyApp(IPubSubTransportFactory private static IPubSubApplication BuildPublisherApp( string url, - IPubSubTransportFactory factory) + IPubSubTransportFactory factory, + IPubSubSecurityKeyProvider? securityKeyProvider = null) { DataSetMetaDataType metaData = NewMetaData(); - return new PubSubApplicationBuilder(NUnitTelemetryContext.Create()) + var writerGroup = new WriterGroupDataType + { + Name = "writer-group", + WriterGroupId = WriterGroupIdValue, + PublishingInterval = 600_000, + DataSetWriters = + [ + new DataSetWriterDataType + { + Name = "writer", + DataSetWriterId = DataSetWriterIdValue, + DataSetName = PublishedDataSetName + } + ] + }; + var readerGroup = new ReaderGroupDataType + { + Name = "discovery-listener" + }; + if (securityKeyProvider is not null) + { + SecureGroup(writerGroup); + SecureGroup(readerGroup); + } + PubSubApplicationBuilder builder = new PubSubApplicationBuilder(NUnitTelemetryContext.Create()) .WithApplicationId("discovery-publisher") .UseConfiguration(new PubSubConfigurationDataType { @@ -221,31 +388,8 @@ private static IPubSubApplication BuildPublisherApp( { Url = url }), - WriterGroups = - [ - new WriterGroupDataType - { - Name = "writer-group", - WriterGroupId = WriterGroupIdValue, - PublishingInterval = 600_000, - DataSetWriters = - [ - new DataSetWriterDataType - { - Name = "writer", - DataSetWriterId = DataSetWriterIdValue, - DataSetName = PublishedDataSetName - } - ] - } - ], - ReaderGroups = - [ - new ReaderGroupDataType - { - Name = "discovery-listener" - } - ] + WriterGroups = [writerGroup], + ReaderGroups = [readerGroup] } ], PublishedDataSets = @@ -259,35 +403,71 @@ private static IPubSubApplication BuildPublisherApp( }) .AddDataSetSource(PublishedDataSetName, new MetaDataOnlySource(metaData)) .UseAllStandardEncoders() - .AddTransportFactory(factory) - .Build(); + .AddTransportFactory(factory); + if (securityKeyProvider is not null) + { + builder.AddSecurityKeyProvider(securityKeyProvider); + } + return builder.Build(); } private static IPubSubApplication BuildSubscriberApp( string url, - IPubSubTransportFactory factory) + IPubSubTransportFactory factory, + IPubSubSecurityKeyProvider? securityKeyProvider = null) { - return new PubSubApplicationBuilder(NUnitTelemetryContext.Create()) + var connection = new PubSubConnectionDataType + { + Name = "subscriber", + TransportProfileUri = Profiles.PubSubUdpUadpTransport, + Address = new ExtensionObject(new NetworkAddressUrlDataType + { + Url = url + }) + }; + if (securityKeyProvider is not null) + { + var readerGroup = new ReaderGroupDataType + { + Name = "secured-listener" + }; + SecureGroup(readerGroup); + connection.ReaderGroups = [readerGroup]; + } + PubSubApplicationBuilder builder = new PubSubApplicationBuilder(NUnitTelemetryContext.Create()) .WithApplicationId("discovery-subscriber") .UseConfiguration(new PubSubConfigurationDataType { - Connections = - [ - new PubSubConnectionDataType - { - Name = "subscriber", - TransportProfileUri = Profiles.PubSubUdpUadpTransport, - Address = new ExtensionObject(new NetworkAddressUrlDataType - { - Url = url - }) - } - ], + Connections = [connection], PublishedDataSets = [] }) .UseAllStandardEncoders() - .AddTransportFactory(factory) - .Build(); + .AddTransportFactory(factory); + if (securityKeyProvider is not null) + { + builder.AddSecurityKeyProvider(securityKeyProvider); + } + return builder.Build(); + } + + private static void SecureGroup(PubSubGroupDataType group) + { + group.SecurityMode = MessageSecurityMode.SignAndEncrypt; + group.SecurityGroupId = SecurityGroupIdValue; + group.SecurityKeyServices = new ArrayOf(new[] + { + new EndpointDescription { EndpointUrl = "opc.tcp://localhost:4840/SecurityKeyService" } + }); + } + + private static PubSubSecurityKeyRing NewKeyRing() + { + var ring = new PubSubSecurityKeyRing(SecurityGroupIdValue); + ring.SetCurrent(TestSecurityKeyFactory.Create( + 1, + PubSubAes256CtrPolicy.Instance.SigningKeyLength, + PubSubAes256CtrPolicy.Instance.EncryptingKeyLength)); + return ring; } private static DataSetMetaDataType NewMetaData() @@ -437,6 +617,247 @@ private void Enqueue(ReadOnlyMemory payload) } } + /// + /// Delivers every frame that one transport sends to all other transports of + /// the bus, and records the frames that carry no SecurityHeader. + /// + private sealed class LoopbackBus + { + private readonly List m_transports = []; + private readonly List m_plaintextFrames = []; + private readonly Lock m_gate = new(); + private int m_announcementCount; + + public int AnnouncementCount => Volatile.Read(ref m_announcementCount); + + public IReadOnlyList PlaintextFrames + { + get + { + lock (m_gate) + { + return [.. m_plaintextFrames]; + } + } + } + + public void Attach(LoopbackTransport transport) + { + lock (m_gate) + { + m_transports.Add(transport); + } + } + + public void Deliver(LoopbackTransport sender, ReadOnlyMemory payload, bool isAnnouncement) + { + if (isAnnouncement) + { + Interlocked.Increment(ref m_announcementCount); + } + var receivers = new List(); + lock (m_gate) + { + if (!UadpDecoder.TryReadOuterPrefix(payload, out _, out bool securityEnabled, out _, out _) || + !securityEnabled) + { + byte[] head = payload.Slice(0, Math.Min(8, payload.Length)).ToArray(); + m_plaintextFrames.Add(BitConverter.ToString(head)); + } + foreach (LoopbackTransport transport in m_transports) + { + if (!ReferenceEquals(transport, sender)) + { + receivers.Add(transport); + } + } + } + foreach (LoopbackTransport receiver in receivers) + { + receiver.Enqueue(payload); + } + } + } + + private sealed class LoopbackTransportFactory : IPubSubTransportFactory + { + private readonly LoopbackBus m_bus; + + public LoopbackTransportFactory(LoopbackBus bus) + { + m_bus = bus; + } + + public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; + + public IPubSubTransport Create( + PubSubConnectionDataType connection, + ITelemetryContext telemetry, + TimeProvider timeProvider) + { + _ = connection; + _ = telemetry; + _ = timeProvider; + var transport = new LoopbackTransport(m_bus); + m_bus.Attach(transport); + return transport; + } + } + + /// + /// An in-memory datagram transport that, like the UDP transport, sends + /// transport-specific discovery announcements to a separate destination. + /// + private sealed class LoopbackTransport : IPubSubTransport, IPubSubDiscoveryAnnouncementTransport + { + private readonly LoopbackBus m_bus; + private readonly Queue m_frames = new(); + private readonly SemaphoreSlim m_signal = new(0, int.MaxValue); + private readonly Lock m_gate = new(); + private bool m_disposed; + + public LoopbackTransport(LoopbackBus bus) + { + m_bus = bus; + } + + public string TransportProfileUri => Profiles.PubSubUdpUadpTransport; + + public PubSubTransportDirection Direction => PubSubTransportDirection.SendReceive; + + public bool IsConnected { get; private set; } + + public uint DiscoveryAnnounceRate => 0; + + public event EventHandler? StateChanged + { + add { } + remove { } + } + + public ValueTask OpenAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + IsConnected = true; + return default; + } + + public ValueTask CloseAsync(CancellationToken cancellationToken = default) + { + _ = cancellationToken; + IsConnected = false; + return default; + } + + public ValueTask SendAsync( + ReadOnlyMemory payload, + string? topic = null, + CancellationToken cancellationToken = default) + { + _ = topic; + cancellationToken.ThrowIfCancellationRequested(); + m_bus.Deliver(this, payload, isAnnouncement: false); + return default; + } + + public ValueTask SendDiscoveryAnnouncementAsync( + ReadOnlyMemory payload, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + m_bus.Deliver(this, payload, isAnnouncement: true); + return default; + } + + public async IAsyncEnumerable ReceiveAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + while (!cancellationToken.IsCancellationRequested) + { + await m_signal.WaitAsync(cancellationToken).ConfigureAwait(false); + PubSubTransportFrame frame; + lock (m_gate) + { + frame = m_frames.Dequeue(); + } + yield return frame; + } + } + + public ValueTask DisposeAsync() + { + lock (m_gate) + { + m_disposed = true; + IsConnected = false; + } + m_signal.Dispose(); + return default; + } + + public void Enqueue(ReadOnlyMemory payload) + { + lock (m_gate) + { + if (m_disposed) + { + return; + } + m_frames.Enqueue(new PubSubTransportFrame( + payload.ToArray(), + topic: null, + DateTimeUtc.From(DateTimeOffset.UtcNow))); + m_signal.Release(); + } + } + } + + /// + /// A key provider whose current key can be made unavailable, as before a + /// Security Key Service delivers the first or the next key. Keys that were + /// already issued can still be looked up by their token. + /// + private sealed class InterruptibleKeyProvider : IPubSubSecurityKeyProvider + { + private readonly IPubSubSecurityKeyProvider m_inner; + private int m_isCurrentKeyAvailable = 1; + + public InterruptibleKeyProvider(IPubSubSecurityKeyProvider inner) + { + m_inner = inner; + } + + public bool IsCurrentKeyAvailable + { + get => Volatile.Read(ref m_isCurrentKeyAvailable) != 0; + set => Volatile.Write(ref m_isCurrentKeyAvailable, value ? 1 : 0); + } + + public string SecurityGroupId => m_inner.SecurityGroupId; + + public event EventHandler? KeyRotated + { + add => m_inner.KeyRotated += value; + remove => m_inner.KeyRotated -= value; + } + + public ValueTask GetCurrentKeyAsync(CancellationToken cancellationToken = default) + { + if (!IsCurrentKeyAvailable) + { + throw new InvalidOperationException("No current security key is available."); + } + return m_inner.GetCurrentKeyAsync(cancellationToken); + } + + public ValueTask TryGetKeyAsync( + uint tokenId, + CancellationToken cancellationToken = default) + { + return m_inner.TryGetKeyAsync(tokenId, cancellationToken); + } + } + private sealed class MetaDataOnlySource : IPublishedDataSetSource { private readonly DataSetMetaDataType m_metaData; diff --git a/tests/Opc.Ua.PubSub.Tests/Encoding/Uadp/UadpDiscoveryTests.cs b/tests/Opc.Ua.PubSub.Tests/Encoding/Uadp/UadpDiscoveryTests.cs index 35f1024d82..be651b8dc0 100644 --- a/tests/Opc.Ua.PubSub.Tests/Encoding/Uadp/UadpDiscoveryTests.cs +++ b/tests/Opc.Ua.PubSub.Tests/Encoding/Uadp/UadpDiscoveryTests.cs @@ -47,6 +47,76 @@ namespace Opc.Ua.PubSub.Tests.Encoding.Uadp [TestSpec("7.2.4.6.9")] public class UadpDiscoveryTests { + /// + /// A secured discovery message differs from the unsecured encoding only + /// by the SecurityHeader flag, and its security boundary is where a + /// receiver's prefix parse ends, so the receiver can verify and decrypt + /// the discovery header and payload. + /// + [Test] + [TestSpec("7.2.4.6.3")] + [TestCase(false)] + [TestCase(true)] + public void SecuredDiscoveryMessagesSplitWhereTheReceiverExpects(bool withDataSetClassId) + { + PubSubNetworkMessageContext context = UadpTestUtilities.NewContext(); + Uuid classId = withDataSetClassId ? (Uuid)Guid.NewGuid() : Uuid.Empty; + PubSubNetworkMessage[] messages = + [ + new UadpDiscoveryResponseMessage + { + PublisherId = PublisherId.FromUInt16(0x4242), + DataSetClassId = classId, + SequenceNumber = 7, + DiscoveryType = UadpDiscoveryType.DataSetMetaData, + DataSetWriterId = 3, + DataSetMetaData = new DataSetMetaDataType { Name = "Secured" }, + StatusCode = StatusCodes.Good + }, + new UadpDiscoveryRequestMessage + { + PublisherId = PublisherId.FromUInt16(0x4242), + DataSetClassId = classId, + DiscoveryType = UadpDiscoveryType.DataSetMetaData, + DataSetWriterIds = new ushort[] { 3 } + } + ]; + + foreach (PubSubNetworkMessage message in messages) + { + byte[] plain = UadpDiscoveryCoder.Encode(message, context); + byte[] secured = UadpEncoder + .EncodeWithSecurityBoundary(message, context, out int payloadOffset) + .ToArray(); + + Assert.That( + UadpDecoder.TryReadOuterPrefix( + secured, out int prefixLength, out bool securityEnabled, out _, out _), + Is.True); + Assert.That(securityEnabled, Is.True); + Assert.That(prefixLength, Is.EqualTo(payloadOffset)); + Assert.That( + secured[1], + Is.EqualTo((byte)(plain[1] | (byte)ExtendedFlags1EncodingMask.SecurityEnabled))); + Assert.That(secured.AsSpan(2).ToArray(), Is.EqualTo(plain.AsSpan(2).ToArray())); + } + } + + /// + /// The security boundary is defined for UADP data, action, and discovery + /// messages only; any other message cannot be secured. + /// + [Test] + public void EncodeWithSecurityBoundaryRejectsAMessageItCannotSecure() + { + PubSubNetworkMessageContext context = UadpTestUtilities.NewContext(); + var message = new Opc.Ua.PubSub.Encoding.Json.JsonNetworkMessage { MessageId = "json" }; + + Assert.That( + () => UadpEncoder.EncodeWithSecurityBoundary(message, context, out _), + Throws.ArgumentException); + } + [Test] public void DiscoveryRequest_DataSetMetaData_RoundTrips() {