diff --git a/docs/PubSub.md b/docs/PubSub.md index bbd847d2c4..739e3396ea 100644 --- a/docs/PubSub.md +++ b/docs/PubSub.md @@ -1222,6 +1222,11 @@ 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 signs and encrypts its + discovery requests, responses, and announcements with the keys of its + SecurityGroup, so only applications that hold those keys can discover each + other. 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/Connections/PubSubConnection.cs b/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs index cd8b78ec5c..48a8dbd594 100644 --- a/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs +++ b/src/Opc.Ua.PubSub/Connections/PubSubConnection.cs @@ -1873,11 +1873,21 @@ namespace Opc.Ua.PubSub.Connections 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; } @@ -2498,36 +2508,16 @@ namespace Opc.Ua.PubSub.Connections m_diagnostics, m_timeProvider); - ReadOnlyMemory payload; - if (m_securityWrapper is not null && - networkMessage is UadpNetworkMessage or UadpDiscoveryResponseMessage) + ReadOnlyMemory? encoded = await EncodeOutboundAsync( + networkMessage, + encoder, + context, + cancellationToken).ConfigureAwait(false); + if (encoded is null) { - payload = await EncodeAndWrapUadpAsync(networkMessage, context, cancellationToken) - .ConfigureAwait(false); - } - else 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. - 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); - } + ReadOnlyMemory payload = encoded.Value; if (m_maxNetworkMessageSize > 0 && payload.Length > m_maxNetworkMessageSize && @@ -2542,6 +2532,57 @@ namespace Opc.Ua.PubSub.Connections .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 + // 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 null; + } + return await encoder.EncodeAsync(networkMessage, context, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// 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) + { + return true; + } + return networkMessage is UadpDiscoveryRequestMessage or UadpDiscoveryResponseMessage && + TransportProfileFamily(TransportProfileUri) == "Uadp"; + } + private ValueTask> EncodeNetworkMessageAsync( PubSubNetworkMessage networkMessage, INetworkMessageEncoder encoder, diff --git a/tests/Opc.Ua.PubSub.Tests/Application/PubSubDiscoveryTests.cs b/tests/Opc.Ua.PubSub.Tests/Application/PubSubDiscoveryTests.cs index 7023b489d1..201fd35a6d 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.DataSets; 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 @@ namespace Opc.Ua.PubSub.Tests.Application 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,71 @@ namespace Opc.Ua.PubSub.Tests.Application 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."); + } + private static IPubSubApplication BuildDiscoveryOnlyApp(IPubSubTransportFactory factory) { return new PubSubApplicationBuilder(NUnitTelemetryContext.Create()) @@ -203,10 +272,35 @@ namespace Opc.Ua.PubSub.Tests.Application 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 +315,8 @@ namespace Opc.Ua.PubSub.Tests.Application { 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 +330,71 @@ namespace Opc.Ua.PubSub.Tests.Application }) .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 +544,201 @@ namespace Opc.Ua.PubSub.Tests.Application } } + /// + /// 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(); + } + } + } + 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 bfbb905617..be651b8dc0 100644 --- a/tests/Opc.Ua.PubSub.Tests/Encoding/Uadp/UadpDiscoveryTests.cs +++ b/tests/Opc.Ua.PubSub.Tests/Encoding/Uadp/UadpDiscoveryTests.cs @@ -102,6 +102,21 @@ namespace Opc.Ua.PubSub.Tests.Encoding.Uadp } } + /// + /// 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() {