mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
Merge branch 'master' into fix/ci-reject-fixture-teardown-failures
This commit is contained in:
+18
-12
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user