Count secured sends and describe Sign-only security accurately

Review feedback:
- Secured NetworkMessages are encoded at the security boundary, outside
  UadpEncoder.EncodeAsync, which is where SentNetworkMessages and
  SentDataSetMessages are counted. Secured data messages were therefore
  never counted, and the discovery messages this branch now secures
  stopped being counted. EncodeAndWrapUadpAsync counts them the same way
  the encoder counts unsecured messages.
- MessageSecurityMode.Sign signs without encrypting, so the DataSet layout
  of a Sign-only announcement stays readable. docs/PubSub.md, the
  MetaDataPublisher remarks, and SendMetaDataAnnouncementAsync now describe
  applying the configured SecurityMode, and limit confidentiality to
  SignAndEncrypt.

Test: MetaDataPublisherTests.SecuredNetworkMessagesAreCountedAsSentAsync
(the counter read 0 before the fix).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Marc
2026-10-09 14:25:04 +02:00
parent 9417ccbbb9
commit 94d43919a9
4 changed files with 64 additions and 16 deletions
+11 -8
View File
@@ -285,9 +285,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. A connection configured for message security signs and
encrypts the UADP announcement with the same keys as its DataSetMessages,
so only subscribers that hold the keys can read the DataSet layout.
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
@@ -1222,11 +1224,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 signs and encrypts its
discovery requests, responses, and announcements with the keys of its
SecurityGroup, so only applications that hold those keys can discover each
other. The MQTT last-will status message is the exception: the broker
publishes it later, so it is sent unsecured.
- 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(
@@ -76,9 +76,10 @@ namespace Opc.Ua.PubSub.Application
/// </para>
/// <para>
/// Announcements go through the connection's send pipeline, so a
/// connection configured for message security signs and encrypts
/// them with the same keys as its DataSetMessages, and refuses to
/// send them when it cannot secure them.
/// 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
@@ -532,7 +533,7 @@ namespace Opc.Ua.PubSub.Application
writer.DataSetWriterId);
// The connection applies its message security, so a secured
// connection never announces the DataSet layout in the clear.
// connection never sends the announcement unsecured.
await connection.SendMetaDataAnnouncementAsync(message, topic, cancellationToken)
.ConfigureAwait(false);
}
@@ -2466,9 +2466,9 @@ namespace Opc.Ua.PubSub.Connections
/// <summary>
/// Sends a DataSetMetaData announcement through the connection's send
/// pipeline, so a connection configured for message security signs and
/// encrypts the announcement like its DataSetMessages and never sends it
/// in the clear.
/// 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>
@@ -2747,13 +2747,14 @@ namespace Opc.Ua.PubSub.Connections
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);
}
@@ -2770,6 +2771,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()
@@ -225,6 +225,38 @@ namespace Opc.Ua.PubSub.Tests.Application
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()
{