Merge branch 'master' into fix/ci-reject-fixture-teardown-failures

This commit is contained in:
Marc Schier
2026-10-09 19:32:37 +02:00
committed by GitHub
11 changed files with 968 additions and 217 deletions
+18 -12
View File
@@ -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(
@@ -75,6 +75,13 @@ namespace Opc.Ua.PubSub.Application
/// <see cref="UadpDiscoveryType.DataSetMetaData"/>.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Lifetime is owned by <see cref="PubSubApplication"/>: started
/// after <c>EnableConnectionsAsync</c> returns, disposed before
/// the connections are torn down.
@@ -84,9 +91,6 @@ namespace Opc.Ua.PubSub.Application
{
private readonly PubSubApplication m_application;
private readonly IDataSetMetaDataRegistry m_registry;
private readonly IReadOnlyDictionary<string, INetworkMessageEncoder> 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<MetaDataPublisher> m_logger;
@@ -106,17 +110,11 @@ namespace Opc.Ua.PubSub.Application
/// the matching transport per writer group.
/// </param>
/// <param name="metaDataRegistry">Shared metadata registry.</param>
/// <param name="encoders">
/// Network-message encoders keyed by transport profile URI.
/// </param>
/// <param name="diagnostics">Diagnostics sink.</param>
/// <param name="telemetry">Telemetry context.</param>
/// <param name="timeProvider">Clock used to stamp MessageIds.</param>
public MetaDataPublisher(
PubSubApplication application,
IDataSetMetaDataRegistry metaDataRegistry,
IReadOnlyDictionary<string, INetworkMessageEncoder> encoders,
IPubSubDiagnostics diagnostics,
ITelemetryContext telemetry,
TimeProvider timeProvider)
{
@@ -128,14 +126,6 @@ namespace Opc.Ua.PubSub.Application
{
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 @@ namespace Opc.Ua.PubSub.Application
}
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<MetaDataPublisher>();
@@ -497,22 +484,16 @@ namespace Opc.Ua.PubSub.Application
{
return;
}
string profile = connection.TransportProfileUri;
string family = TransportProfileFamily(profile);
Uuid classId = metaData.DataSetClassId == Guid.Empty
? Uuid.Empty
: new Uuid(metaData.DataSetClassId);
ReadOnlyMemory<byte> 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 @@ namespace Opc.Ua.PubSub.Application
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 @@ namespace Opc.Ua.PubSub.Application
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<string, INetworkMessageEncoder> 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 @@ namespace Opc.Ua.PubSub.Application
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);
}
}
@@ -662,8 +662,6 @@ namespace Opc.Ua.PubSub.Application
var metaDataPublisher = new MetaDataPublisher(
this,
MetaDataRegistry,
m_encoderMap,
m_aggregatingDiagnostics,
m_telemetry,
m_timeProvider);
try
+127 -31
View File
@@ -1866,6 +1866,24 @@ namespace Opc.Ua.PubSub.Connections
{
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 @@ namespace Opc.Ua.PubSub.Connections
out IPubSubDiscoveryAnnouncementTransport? announcementTransport) &&
encoder is not null)
{
ReadOnlyMemory<byte> payload = await EncodeNetworkMessageAsync(
var context = new PubSubNetworkMessageContext(
ServiceMessageContext.CreateEmpty(m_telemetry),
m_metaDataRegistry,
m_diagnostics,
m_timeProvider);
ReadOnlyMemory<byte>? 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 @@ namespace Opc.Ua.PubSub.Connections
return SendNetworkMessageAsync(networkMessage, topic, writerGroup: null, cancellationToken);
}
/// <summary>
/// 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.
/// </summary>
/// <param name="announcement">UADP discovery response or JSON metadata message.</param>
/// <param name="topic">Broker topic, or <see langword="null"/> for datagram transports.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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,36 +2526,16 @@ namespace Opc.Ua.PubSub.Connections
m_diagnostics,
m_timeProvider);
ReadOnlyMemory<byte> payload;
if (m_securityWrapper is not null &&
networkMessage is UadpNetworkMessage uadp)
ReadOnlyMemory<byte>? encoded = await EncodeOutboundAsync(
networkMessage,
encoder,
context,
cancellationToken).ConfigureAwait(false);
if (encoded is null)
{
payload = await EncodeAndWrapUadpAsync(uadp, 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<byte> payload = encoded.Value;
if (m_maxNetworkMessageSize > 0 &&
payload.Length > m_maxNetworkMessageSize &&
@@ -2525,6 +2550,57 @@ namespace Opc.Ua.PubSub.Connections
.ConfigureAwait(false);
}
/// <summary>
/// Encodes a NetworkMessage for sending and applies the message security of
/// the connection. Returns <see langword="null"/>, and the message must not be
/// sent, when the connection requires message security that the message
/// cannot carry.
/// </summary>
private async ValueTask<ReadOnlyMemory<byte>?> 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);
}
/// <summary>
/// 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.
/// </summary>
private bool IsSecurableUadpMessage(PubSubNetworkMessage networkMessage)
{
if (networkMessage is UadpNetworkMessage)
{
return true;
}
return networkMessage is UadpDiscoveryRequestMessage or UadpDiscoveryResponseMessage &&
TransportProfileFamily(TransportProfileUri) == "Uadp";
}
private ValueTask<ReadOnlyMemory<byte>> EncodeNetworkMessageAsync(
PubSubNetworkMessage networkMessage,
INetworkMessageEncoder encoder,
@@ -2685,17 +2761,18 @@ namespace Opc.Ua.PubSub.Connections
}
private async ValueTask<ReadOnlyMemory<byte>> EncodeAndWrapUadpAsync(
UadpNetworkMessage message,
PubSubNetworkMessage message,
PubSubNetworkMessageContext context,
CancellationToken cancellationToken)
{
ReadOnlyMemory<byte> wrapped;
try
{
ReadOnlyMemory<byte> encoded = UadpEncoder.EncodeWithSecurityBoundary(
message, context, out int payloadOffset);
ReadOnlyMemory<byte> prefix = encoded[..payloadOffset];
ReadOnlyMemory<byte> inner = encoded[payloadOffset..];
return await m_securityWrapper!
wrapped = await m_securityWrapper!
.WrapAsync(prefix, inner, m_securityWrapOptions, cancellationToken)
.ConfigureAwait(false);
}
@@ -2712,6 +2789,17 @@ namespace Opc.Ua.PubSub.Connections
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 @@ namespace Opc.Ua.PubSub.Connections
[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);
}
}
@@ -119,6 +119,31 @@ namespace Opc.Ua.PubSub.Encoding.Uadp
public static byte[] Encode(
PubSubNetworkMessage message,
PubSubNetworkMessageContext context)
{
return Encode(message, context, securityEnabled: false, out _);
}
/// <summary>
/// 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).
/// </summary>
/// <param name="message">Source message; must be a
/// <see cref="UadpDiscoveryRequestMessage"/> or
/// <see cref="UadpDiscoveryResponseMessage"/>.</param>
/// <param name="context">Network message context.</param>
/// <param name="securityEnabled">Sets the SecurityHeader bit in
/// ExtendedFlags1.</param>
/// <param name="payloadOffset">Boundary between the outer prefix
/// and the inner payload.</param>
/// <exception cref="ArgumentNullException"></exception>
/// <exception cref="InvalidOperationException"></exception>
internal static byte[] Encode(
PubSubNetworkMessage message,
PubSubNetworkMessageContext context,
bool securityEnabled,
out int payloadOffset)
{
if (message is null)
{
@@ -131,9 +156,9 @@ namespace Opc.Ua.PubSub.Encoding.Uadp
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 @@ namespace Opc.Ua.PubSub.Encoding.Uadp
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 @@ namespace Opc.Ua.PubSub.Encoding.Uadp
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 @@ namespace Opc.Ua.PubSub.Encoding.Uadp
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 @@ namespace Opc.Ua.PubSub.Encoding.Uadp
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,
@@ -147,12 +147,12 @@ namespace Opc.Ua.PubSub.Encoding.Uadp
}
/// <summary>
/// Encodes a UADP data or action NetworkMessage with the
/// Encodes a UADP data, action or discovery NetworkMessage with the
/// <c>ExtendedFlags1.SecurityEnabled</c> bit set and reports the
/// byte offset at which the security wrapper must insert the
/// SecurityHeader.
/// </summary>
/// <param name="networkMessage">UADP data or action message.</param>
/// <param name="networkMessage">UADP data, action or discovery message.</param>
/// <param name="context">Network message context.</param>
/// <param name="payloadOffset">Boundary between outer prefix and inner payload.</param>
/// <exception cref="ArgumentException"></exception>
@@ -171,8 +171,14 @@ namespace Opc.Ua.PubSub.Encoding.Uadp
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));
}
@@ -144,11 +144,12 @@ namespace Opc.Ua.Gds.Tests
Assert.That(applyChangesRequired, Is.True);
ISession other = await OpenAdminSessionAsync().ConfigureAwait(false);
using Certificate certificate = CreateSelfSigned();
await AssertStatusAsync(
() => AppTrustList(other).OpenAsync(WriteEraseExisting).AsTask(),
StatusCodes.BadTransactionPending).ConfigureAwait(false);
await AssertStatusAsync(
() => AppTrustList(other).AddCertificateAsync(CreateSelfSigned().RawData.ToByteString(), true).AsTask(),
() => AppTrustList(other).AddCertificateAsync(certificate.RawData.ToByteString(), true).AsTask(),
StatusCodes.BadTransactionPending).ConfigureAwait(false);
await ApplyChangesAsync(owner).ConfigureAwait(false);
@@ -42,6 +42,9 @@ using Opc.Ua.PubSub.Encoding;
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 @@ namespace Opc.Ua.PubSub.Tests.Application
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 @@ namespace Opc.Ua.PubSub.Tests.Application
Assert.That(response.DataSetWriterId, Is.EqualTo(DataSetWriterIdValue));
}
/// <summary>
/// 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.
/// </summary>
[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<UadpDiscoveryResponseMessage>());
var response = (UadpDiscoveryResponseMessage)decoded!;
Assert.That(response.DiscoveryType, Is.EqualTo(UadpDiscoveryType.DataSetMetaData));
Assert.That(response.DataSetMetaData?.Name, Is.EqualTo("pds-1"));
}
/// <summary>
/// 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.
/// </summary>
[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 @@ namespace Opc.Ua.PubSub.Tests.Application
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 @@ namespace Opc.Ua.PubSub.Tests.Application
}
})
};
if (securityKeyProvider is not null)
{
WriterGroupDataType writerGroup = connection.WriterGroups[0];
writerGroup.SecurityMode = MessageSecurityMode.SignAndEncrypt;
writerGroup.SecurityGroupId = SecurityGroupIdValue;
writerGroup.SecurityKeyServices = new ArrayOf<EndpointDescription>(new[]
{
new EndpointDescription { EndpointUrl = "opc.tcp://localhost:4840/SecurityKeyService" }
});
}
var pds = new PublishedDataSetDataType
{
Name = "pds-1",
@@ -426,9 +528,23 @@ namespace Opc.Ua.PubSub.Tests.Application
{
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(
@@ -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,144 @@ namespace Opc.Ua.PubSub.Tests.Application
Assert.That(endpoints.PublisherEndpoints[0].EndpointUrl, Is.EqualTo(url));
}
/// <summary>
/// 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.
/// </summary>
[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.");
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// A discovery response that cannot be secured is dropped without ending the
/// receive loop of the publisher, which answers the next request.
/// </summary>
[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 @@ 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 +388,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 +403,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<EndpointDescription>(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 @@ namespace Opc.Ua.PubSub.Tests.Application
}
}
/// <summary>
/// Delivers every frame that one transport sends to all other transports of
/// the bus, and records the frames that carry no SecurityHeader.
/// </summary>
private sealed class LoopbackBus
{
private readonly List<LoopbackTransport> m_transports = [];
private readonly List<string> m_plaintextFrames = [];
private readonly Lock m_gate = new();
private int m_announcementCount;
public int AnnouncementCount => Volatile.Read(ref m_announcementCount);
public IReadOnlyList<string> 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<byte> payload, bool isAnnouncement)
{
if (isAnnouncement)
{
Interlocked.Increment(ref m_announcementCount);
}
var receivers = new List<LoopbackTransport>();
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;
}
}
/// <summary>
/// An in-memory datagram transport that, like the UDP transport, sends
/// transport-specific discovery announcements to a separate destination.
/// </summary>
private sealed class LoopbackTransport : IPubSubTransport, IPubSubDiscoveryAnnouncementTransport
{
private readonly LoopbackBus m_bus;
private readonly Queue<PubSubTransportFrame> 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<PubSubTransportStateChangedEventArgs>? 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<byte> payload,
string? topic = null,
CancellationToken cancellationToken = default)
{
_ = topic;
cancellationToken.ThrowIfCancellationRequested();
m_bus.Deliver(this, payload, isAnnouncement: false);
return default;
}
public ValueTask SendDiscoveryAnnouncementAsync(
ReadOnlyMemory<byte> payload,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
m_bus.Deliver(this, payload, isAnnouncement: true);
return default;
}
public async IAsyncEnumerable<PubSubTransportFrame> 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<byte> payload)
{
lock (m_gate)
{
if (m_disposed)
{
return;
}
m_frames.Enqueue(new PubSubTransportFrame(
payload.ToArray(),
topic: null,
DateTimeUtc.From(DateTimeOffset.UtcNow)));
m_signal.Release();
}
}
}
/// <summary>
/// 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.
/// </summary>
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<PubSubKeyRotatedEventArgs>? KeyRotated
{
add => m_inner.KeyRotated += value;
remove => m_inner.KeyRotated -= value;
}
public ValueTask<PubSubSecurityKey> GetCurrentKeyAsync(CancellationToken cancellationToken = default)
{
if (!IsCurrentKeyAvailable)
{
throw new InvalidOperationException("No current security key is available.");
}
return m_inner.GetCurrentKeyAsync(cancellationToken);
}
public ValueTask<PubSubSecurityKey?> TryGetKeyAsync(
uint tokenId,
CancellationToken cancellationToken = default)
{
return m_inner.TryGetKeyAsync(tokenId, cancellationToken);
}
}
private sealed class MetaDataOnlySource : IPublishedDataSetSource
{
private readonly DataSetMetaDataType m_metaData;
@@ -47,6 +47,76 @@ namespace Opc.Ua.PubSub.Tests.Encoding.Uadp
[TestSpec("7.2.4.6.9")]
public class UadpDiscoveryTests
{
/// <summary>
/// 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.
/// </summary>
[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()));
}
}
/// <summary>
/// The security boundary is defined for UADP data, action, and discovery
/// messages only; any other message cannot be secured.
/// </summary>
[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()
{
@@ -75,6 +75,10 @@ namespace Opc.Ua.Types.Tests.Encoders
private const int kBuilderStack = 256 * 1024 * 1024;
private const int kUnboundedLimit = 1_000_000;
// Twice the deepest nesting of any codec and shape that fits a one
// megabyte stack before the stack check fires.
private const int kBeyondTheStackDepth = 5000;
private static readonly Codec[] s_decoderCodecs =
[Codec.Binary, Codec.Json, Codec.Xml, Codec.XmlParser];
private static readonly Codec[] s_encoderCodecs = [Codec.Binary, Codec.Json, Codec.Xml];
@@ -86,6 +90,11 @@ namespace Opc.Ua.Types.Tests.Encoders
Shape.ExtensionObjectChain,
Shape.EncodeableTree
];
// A thread can get more stack than it asks for: on Linux, glibc runs it
// on the cached stack of an exited thread up to four times the requested
// size, and the stack check measures the stack the thread got. A quarter
// megabyte request can therefore run on a one megabyte stack.
private static readonly int[] s_stackSizes = [kOneMegabyte, kQuarterMegabyte];
private static IEnumerable<TestCaseData> DecoderCases()
@@ -140,30 +149,51 @@ namespace Opc.Ua.Types.Tests.Encoders
public void DecodeWithUnboundedNestingLimitFailsBeforeTheStackOverflows(Codec codec, Shape shape)
{
// Without a usable nesting limit only the stack check stands between
// a deep message and a crash. The JSON encoder cannot write more than
// 1000 levels, which still overflowed a small stack before.
int depth = codec == Codec.Json ? 450 : 2000;
object input = EncodeOnLargeStack(codec, Build(shape, depth));
// a deep message and a crash. The JsonEncoder cannot write more than
// 1000 levels, so the JSON input is written by hand.
object input = codec == Codec.Json
? CreateNestedJson(shape, kBeyondTheStackDepth)
: EncodeOnLargeStack(codec, Build(shape, kBeyondTheStackDepth));
ServiceResultException sre = RunOnThread(
kQuarterMegabyte,
() => Decode(codec, shape, input, CreateContext(kUnboundedLimit)));
foreach (int stackSize in s_stackSizes)
{
ServiceResultException sre = RunOnThread(
stackSize,
() => Decode(codec, shape, input, CreateContext(kUnboundedLimit)));
Assert.That(sre, Is.Not.Null);
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded));
Assert.That(sre, Is.Not.Null, $"Stack of {stackSize} bytes");
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded));
}
}
[TestCaseSource(nameof(EncoderCases))]
public void EncodeWithUnboundedNestingLimitFailsBeforeTheStackOverflows(Codec codec, Shape shape)
{
object graph = Build(shape, 2000);
object graph = Build(shape, kBeyondTheStackDepth);
ServiceResultException sre = RunOnThread(
kQuarterMegabyte,
() => Encode(codec, graph, CreateContext(kUnboundedLimit)));
foreach (int stackSize in s_stackSizes)
{
ServiceResultException sre = RunOnThread(
stackSize,
() => Encode(codec, graph, CreateContext(kUnboundedLimit)));
Assert.That(sre, Is.Not.Null);
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded));
Assert.That(sre, Is.Not.Null, $"Stack of {stackSize} bytes");
Assert.That(sre.StatusCode, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded));
}
}
[Test]
public void NestedJsonMatchesTheJsonEncoder([Values] Shape shape)
{
// The JSON inputs nested beyond the encoder's reach are written by
// hand. They must stay what the JsonEncoder writes, or the decoder
// tests stop exercising the encoding a peer sends.
foreach (int depth in new[] { 1, 2, 7 })
{
string encoded = (string)EncodeOnLargeStack(Codec.Json, Build(shape, depth));
Assert.That(CreateNestedJson(shape, depth), Is.EqualTo(encoded), $"Depth {depth}");
}
}
[TestCase(Codec.Xml)]
@@ -345,6 +375,50 @@ namespace Opc.Ua.Types.Tests.Encoders
}
}
/// <summary>
/// Writes the JSON that the JsonEncoder writes for <see cref="Build"/>
/// at any depth. The encoder itself cannot nest deeper than 1000 JSON
/// levels.
/// </summary>
private static string CreateNestedJson(Shape shape, int depth)
{
const string int32 = "{\"UaType\":6,\"Value\":1}";
(string head, string open, string core, string close, string tail, int levels) = shape switch
{
Shape.VariantArray => ("{\"Value\":", "{\"UaType\":24,\"Value\":[", int32, "]}", "}", depth),
Shape.DataValue => ("{\"Value\":", "{\"UaType\":23,\"Value\":", int32, "}", "}", depth),
Shape.ExtensionObjectVariant => (
"{\"Value\":",
"{\"UaType\":22,\"Value\":{\"UaTypeId\":\"i=88901\",\"Value\":",
int32,
"}}",
"}",
depth),
Shape.ExtensionObjectChain => (
"{\"Value\":{\"UaType\":22,\"Value\":",
"{\"UaTypeId\":\"i=88911\",\"Child\":",
"null",
"}",
"}}",
depth + 1),
_ => ("{\"Value\":", "{\"Children\":[", "{\"Children\":null}", "]}", "}", depth)
};
var builder = new StringBuilder(
head.Length + core.Length + tail.Length + (levels * (open.Length + close.Length)));
builder.Append(head);
for (int ii = 0; ii < levels; ii++)
{
builder.Append(open);
}
builder.Append(core);
for (int ii = 0; ii < levels; ii++)
{
builder.Append(close);
}
return builder.Append(tail).ToString();
}
/// <summary>
/// Encodes the input for a decoder test on a large stack with a limit
/// that allows its depth.