Skip to content

Secure the UADP DataSetMetaData announcement on secured connections - #4712

Merged
marcschier merged 7 commits into
masterfrom
fix/pubsub-secure-metadata-announcement
Oct 9, 2026
Merged

marcschier merged 7 commits into
masterfrom
fix/pubsub-secure-metadata-announcement

Conversation

@marcschier

@marcschier marcschier commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Description

MetaDataPublisher encoded the DataSetMetaData announcement itself and called IPubSubTransport.SendAsync directly. That bypassed the connection's security wrapper and its fail-closed check. A WriterGroup configured for SignAndEncrypt therefore announced its DataSet layout (field names, types, version) unsigned and unencrypted. A secured subscriber dropped the announcement (Dropping unsecured inbound frame ... requiring SignAndEncrypt, documented as an expected warning in docs/PubSub.md), and any listener could read it. OPC 10000-14 §6.2.5.2 applies the WriterGroup's SecurityMode to the NetworkMessages it publishes, and §7.2.4.6.3 carries the security of a discovery announcement in its SecurityHeader.

Confirmed on the wire: the documented udp-uadp exchange sent secured DataSetMessages plus one DiscoveryResponse (announcement type 2, DataSetMetaData) with ExtendedFlags1 0x81, meaning the SecurityHeader bit was clear.

Review feedback widened the scope to every UADP discovery message of a secured connection, and to the diagnostics of secured messages.

Fix:

  • MetaDataPublisher builds the announcement and hands it to the connection (PubSubConnection.SendMetaDataAnnouncementAsync).
  • One encode-and-secure step (PubSubConnection.EncodeOutboundAsync) now serves data messages, discovery requests and responses, and the announcements that UDP and Ethernet send to their transport-specific discovery destination (ApplicationInformation, PublisherEndpoints, PubSubConnection). On a connection configured for message security, it applies the SecurityMode (Sign, or SignAndEncrypt) to UADP messages and refuses to send any message it cannot secure.
  • Before, a secured connection dropped its own discovery requests, so a secured subscriber could not ask for metadata, writer configuration, or endpoints. It also refused metadata and writer-configuration probe responses, and sent the transport-specific announcements in plaintext.
  • A discovery response or announcement that cannot be secured or sent is logged (DiscoveryMessageNotSent, Warning) and dropped. Securing announcements makes them depend on a current key; without this, a key provider with no key yet, for example before a Security Key Service delivers the first key, made StartAsync fail, and a probe response that could not be secured ended the receive loop. Requests sent through RequestDiscoveryAsync still throw to the caller.
  • Secured messages are counted in SentNetworkMessages and SentDataSetMessages like unsecured ones. Before, they bypassed the encoder that counts them, so secured data messages were never counted.
  • UadpDiscoveryCoder can encode a discovery message with the SecurityHeader bit set, and reports the security boundary after the PublisherId (and DataSetClassId), which is where UadpDecoder.TryReadOuterPrefix ends the prefix. UadpEncoder.EncodeWithSecurityBoundary accepts discovery messages.
  • The receive path is unchanged: it already unwraps secured discovery frames and registers authenticated metadata.
  • Not changed: the MQTT last-will status message is registered with the broker when the connection opens and published later, so it cannot carry a current SecurityHeader; it stays unsecured.
  • docs/PubSub.md no longer lists the unsecured-frame warning. It describes how the SecurityMode applies to announcements and discovery (Sign authenticates; SignAndEncrypt also encrypts) and the last-will exception.

Tests:

  • MetaDataPublisherTests.UadpPathSecuresTheAnnouncementOfASecuredWriterGroupAsync: the announcement of a SignAndEncrypt WriterGroup carries a SecurityHeader, does not contain the DataSet name in cleartext, and unwraps and decodes with the subscriber's keys. Before the fix, the announcement had no SecurityHeader.
  • MetaDataPublisherTests.SecuredNetworkMessagesAreCountedAsSentAsync: before the fix, SentNetworkMessages read 0 on a secured connection.
  • PubSubDiscoveryTests.SecuredSubscriberDiscoversSecuredPublisherWithoutPlaintextFramesAsync: a secured subscriber and a secured publisher, connected through an in-memory transport that also sends announcements to a separate destination, discover metadata, writer configuration, and endpoints, and no frame lacks a SecurityHeader. Before the fix, the metadata request was dropped. With only the announcement change reverted, the PublisherEndpoints response was dropped.
  • PubSubDiscoveryTests.StartupAnnouncementsWithoutACurrentKeyDoNotFailTheStartAsync and ReceiveLoopSurvivesADiscoveryResponseThatCannotBeSecuredAsync: both failed before the fail-soft change.
  • UadpDiscoveryTests.SecuredDiscoveryMessagesSplitWhereTheReceiverExpects: for requests and responses, with and without a DataSetClassId, the secured encoding differs from the unsecured one only by the SecurityHeader flag, and its boundary equals the receiver's prefix length.
  • UadpDiscoveryTests.EncodeWithSecurityBoundaryRejectsAMessageItCannotSecure.

Validation

Command Result
dotnet test tests/Opc.Ua.PubSub.Tests -f net10.0 -p:CustomTestTarget=net10.0 1498 passed, 1 skipped (the UDP multicast loopback discovery test, which this host skips)
dotnet test tests/Opc.Ua.PubSub.Tests -f net48 -p:CustomTestTarget=net48 1498 passed, 1 skipped
dotnet test on net10.0 for Opc.Ua.PubSub.Udp.Tests, .Eth.Tests, .Mqtt.Tests, .Server.Tests, fuzzing/Opc.Ua.PubSub.Fuzz.Tests 234, 55, 157, 177, 149 passed
dotnet test on net10.0 for Opc.Ua.PubSub.Kafka.Tests, .Adapter.Tests, .Diagnostics.Tests, Opc.Ua.Redundancy.PubSub.Tests (on 9417ccb) 92 (1 skipped), 152, 53 (1 skipped), 45 passed
tests\Opc.Ua.Aot.Tests\bin\Debug\net10.0\Opc.Ua.Aot.Tests.exe --treenode-filter "/*/*/PubSubAotTests/*" (JIT; the AOT job failed on 94d4391) 7 of 7 passed, including StartsAndStopsPublisher_UdpUadp
dotnet build src/Opc.Ua.PubSub -c Release (all target frameworks) 0 warnings
The docs/PubSub.md first exchange, run verbatim, with #4710 applied (before the review changes) The announcement is secured on the wire (ExtendedFlags1 0x91, 268 bytes); 14 DataSets; no unsecured-frame warning; the subscriber logs Registered inbound metadata for writer 1
After merging master (#4710, #4711, #4716), on 83567ce: dotnet build src/Opc.Ua.PubSub -c Release 0 warnings
Same merge: dotnet test tests/Opc.Ua.PubSub.Tests -f net10.0; the changed fixtures on net48; dotnet test tests/Opc.Ua.PubSub.Udp.Tests -f net10.0 1498 passed, 1 skipped; 132 passed, 1 skipped; 237 passed
Same merge: Opc.Ua.Aot.Tests.exe --treenode-filter "/*/*/PubSubAotTests/*" (JIT) 8 of 8 passed, including #4710's SamplePublisherDeliversSecuredDataSetToSampleSubscriberAsync
Same merge: the docs/PubSub.md first exchange, run verbatim on opc.udp://127.0.0.1:4840, then the publisher alone against a UDP listener 9 DataSets in 10 seconds and no warning on either side; the listener saw 9 DataSetMessages and 1 DiscoveryResponse (the startup announcement), all with the SecurityHeader bit set

Related Issues

Checklist

  • I have signed the CLA and read the CONTRIBUTING doc.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added all necessary documentation.
  • I have verified that my changes do not introduce (new) build or analyzer warnings.
  • I ran all tests locally using the UA.slnx solution against at least .net framework and .net 10, and all passed.
  • I fixed all failing and flaky tests in the CI pipelines and all CodeQL warnings.
  • I have addressed all PR feedback received.

MetaDataPublisher encoded the DataSetMetaData announcement itself and
called IPubSubTransport.SendAsync directly. That bypassed the connection's
security wrapper and its fail-closed check, so a WriterGroup configured for
SignAndEncrypt announced its DataSet layout (field names, types, version)
unsigned and unencrypted. A secured subscriber dropped the announcement
("Dropping unsecured inbound frame ... requiring SignAndEncrypt"), and any
listener could read it. OPC 10000-14 6.2.5.2 applies the WriterGroup's
SecurityMode to the NetworkMessages it publishes, and 7.2.4.6.3 carries the
security of a discovery announcement in its SecurityHeader.

Cause, confirmed on the wire: the documented udp-uadp exchange sent eight
secured DataSetMessages and one DiscoveryResponse (announcement type 2,
DataSetMetaData) with ExtendedFlags1 = 0x81, i.e. without the SecurityHeader
bit.

Fix:
- MetaDataPublisher builds the announcement and hands it to the connection
  (PubSubConnection.SendMetaDataAnnouncementAsync), which applies the
  connection's message security, or refuses to send when it cannot secure
  the message.
- UadpDiscoveryCoder can encode a discovery message with the SecurityHeader
  bit and reports the security boundary after the PublisherId (and
  DataSetClassId), where UadpDecoder.TryReadOuterPrefix ends the prefix.
  UadpEncoder.EncodeWithSecurityBoundary accepts discovery messages.
- PubSubConnection.SendNetworkMessageAsync wraps UADP discovery responses.
  Before, a secured connection refused them (metadata and writer
  configuration probe responses, and the MQTT discovery topics); now it
  sends them secured. The receive path is unchanged: it already unwraps
  secured discovery frames and registers authenticated metadata.

Tests:
- MetaDataPublisherTests.UadpPathSecuresTheAnnouncementOfASecuredWriterGroupAsync:
  the announcement of a SignAndEncrypt WriterGroup carries a SecurityHeader,
  does not contain the DataSet name in cleartext, and unwraps and decodes
  with the subscriber's keys. Red before the fix (no SecurityHeader).
- UadpDiscoveryTests.SecuredDiscoveryMessagesSplitWhereTheReceiverExpects:
  for requests and responses, with and without a DataSetClassId, the
  secured encoding differs from the unsecured one only by the SecurityHeader
  flag, and its boundary equals the receiver's prefix length.

Validation: the documented exchange (with the sample KeyNonce fix applied)
now sends the announcement secured, logs no unsecured-frame warning, and the
subscriber logs "Registered inbound metadata for writer 1". docs/PubSub.md
no longer lists the unsecured-frame warning and describes the secured
announcement.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@marcschier
marcschier marked this pull request as ready for review October 9, 2026 11:05
Copilot AI balanced review requested due to automatic review settings October 9, 2026 11:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Secured discovery requests are dropped, while transport-specific discovery announcements still bypass security.

2 open findings
What changed in this PR

Routes UADP metadata announcements through connection security and adds secured discovery encoding.

Changes:

  • Adds security boundaries for UADP discovery messages.
  • Secures metadata announcements through the connection pipeline.
  • Adds regression tests and updates PubSub documentation.
File Description
src/​Opc.Ua.PubSub/​Application/​MetaDataPublisher.cs Delegates announcement sending to the connection.
src/​Opc.Ua.PubSub/​Application/​PubSubApplication.cs Updates publisher construction.
src/​Opc.Ua.PubSub/​Connections/​PubSubConnection.cs Wraps discovery responses with UADP security.
src/​Opc.Ua.PubSub/​Encoding/​Uadp/​UadpDiscoveryCoder.cs Adds secured discovery encoding boundaries.
src/​Opc.Ua.PubSub/​Encoding/​Uadp/​UadpEncoder.cs Supports secured discovery messages.
tests/​Opc.Ua.PubSub.Tests/​Application/​MetaDataPublisherTests.cs Tests encrypted metadata announcements.
tests/​Opc.Ua.PubSub.Tests/​Encoding/​Uadp/​UadpDiscoveryTests.cs Tests discovery security boundaries.
docs/​PubSub.md Documents secured metadata announcements.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Opc.Ua.PubSub/Connections/PubSubConnection.cs Outdated
Comment thread src/Opc.Ua.PubSub/Connections/PubSubConnection.cs Outdated
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Code coverage

✅ Coverage gate passed.

Check Result Threshold
✅ Project line rate 88.61% (306546/345941 lines) >= 70.00%
✅ Project branch rate 79.06% >= 60.00%
✅ Patch coverage 96.39% (80/83 changed lines) >= 60.00% (<= 100 changed lines, advisory)
ℹ️ Baseline delta (advisory) +15.01 pp 73.60% recorded
Uncovered changed lines
  • src/Opc.Ua.PubSub/Connections/PubSubConnection.cs: 1906, 2536, 2583

Coverage is above the recorded baseline - consider ratcheting coverage-thresholds.json.

Thresholds live in coverage-thresholds.json. Whole report before exclusions: line 87.75%, branch 78.26%.

Review feedback:
- A secured connection dropped its own discovery requests: the fail-closed
  check refused every UadpDiscoveryRequestMessage, so a secured subscriber
  could not ask a publisher for metadata, writer configuration, or
  endpoints. Requests are now secured like responses on connections that
  use the UADP encoding.
- The ApplicationInformation, PublisherEndpoints, and PubSubConnection
  announcements that UDP and Ethernet send to their transport-specific
  discovery destination were still encoded in plaintext.
  SendDiscoveryResponseAsync now uses the same encode-and-secure step as
  SendNetworkMessageAsync (EncodeOutboundAsync), which also refuses to send
  a message that a secured connection cannot protect.

The MQTT last-will status message is unchanged: it is registered with the
broker when the connection opens and published later, so it cannot carry a
current SecurityHeader.

docs/PubSub.md describes the secured discovery and this exception.

Tests:
- PubSubDiscoveryTests.SecuredSubscriberDiscoversSecuredPublisherWithoutPlaintextFramesAsync
  connects a secured publisher and a secured subscriber through an
  in-memory transport that also sends announcements to a separate
  destination. Metadata, writer configuration, and endpoints are
  discovered, and no frame without a SecurityHeader is sent. Before the
  fix the metadata request was dropped.
- UadpDiscoveryTests.EncodeWithSecurityBoundaryRejectsAMessageItCannotSecure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Secured sends are omitted from diagnostics, and the documentation incorrectly promises encryption for sign-only security.

4 open findings
2 resolved since last review

🧠 Review effort: Balanced

Comment thread src/Opc.Ua.PubSub/Connections/PubSubConnection.cs
Comment thread docs/PubSub.md Outdated
Comment thread src/Opc.Ua.PubSub/Application/MetaDataPublisher.cs Outdated
Comment thread src/Opc.Ua.PubSub/Connections/PubSubConnection.cs Outdated
marcschier and others added 4 commits October 9, 2026 14:25
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>
Securing the discovery announcements (previous commits) made them depend on
a current security key. When the key provider has none yet, as before a
Security Key Service delivers the first key, or when the key does not fit
the policy (the reference sample's 12-byte KeyNonce, fixed in #4710),
wrapping the startup announcements threw out of EnableAsync, and StartAsync
failed. The aot-ubuntu-latest job failed this way in
PubSubAotTests.StartsAndStopsPublisher_UdpUadp. A response to a discovery
probe that could not be secured threw into the receive loop and ended it.

SendDiscoveryResponseAsync now logs a discovery message that cannot be
secured or sent (DiscoveryMessageNotSent, Warning) and drops it, as the UDP
transport already does for network errors of discovery announcements.
Requests sent through RequestDiscoveryAsync still throw to the caller.

Tests (PubSubDiscoveryTests), both red before the fix:
- StartupAnnouncementsWithoutACurrentKeyDoNotFailTheStartAsync
- ReceiveLoopSurvivesADiscoveryResponseThatCannotBeSecuredAsync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@marcschier
marcschier requested a review from romanett October 9, 2026 14:33
@marcschier
marcschier merged commit 7f14fb8 into master Oct 9, 2026
109 checks passed
@marcschier
marcschier deleted the fix/pubsub-secure-metadata-announcement branch October 9, 2026 14:52
@codecov

codecov Bot commented Oct 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.56627% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.85%. Comparing base (60dc5ab) to head (349f366).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
src/Opc.Ua.PubSub/Connections/PubSubConnection.cs 86.53% 3 Missing and 4 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##           master    #4712   +/-   ##
=======================================
  Coverage   82.84%   82.85%           
=======================================
  Files        2267     2267           
  Lines      345943   345941    -2     
  Branches    60303    60303           
=======================================
+ Hits       286609   286620   +11     
+ Misses      39238    39230    -8     
+ Partials    20096    20091    -5     
Flag Coverage Δ
actions 82.85% <91.56%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/Opc.Ua.PubSub/Application/MetaDataPublisher.cs 80.48% <100.00%> (+3.64%) ⬆️
src/Opc.Ua.PubSub/Application/PubSubApplication.cs 84.67% <ø> (+0.04%) ⬆️
.../Opc.Ua.PubSub/Encoding/Uadp/UadpDiscoveryCoder.cs 79.53% <100.00%> (+0.37%) ⬆️
src/Opc.Ua.PubSub/Encoding/Uadp/UadpEncoder.cs 86.66% <100.00%> (+1.86%) ⬆️
src/Opc.Ua.PubSub/Connections/PubSubConnection.cs 88.49% <86.53%> (+0.23%) ⬆️

... and 37 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

marcschier added a commit that referenced this pull request Oct 11, 2026
…#4724)

# Description

#4712 makes a connection configured for message security send its
DataSetMetaData announcement through the connection's security step, so
the announcement is never sent unsecured. Its tests cover the UADP
announcement. This adds the JSON case.

The UADP security wrapper cannot protect a JSON message, so a connection
whose WriterGroup uses `SignAndEncrypt` must refuse the JSON metadata
announcement rather than publish the DataSet layout in plaintext.
`MetaDataPublisherTests.JsonAnnouncementOfASecuredWriterGroupIsRefusedAsync`
checks that nothing is published on the metadata topic and that the
refusal is counted in `EncryptionErrors`. `PubSubApplication.StartAsync`
returns after the initial announcement was sent or refused, so the check
needs no wait.

The test covers the fail-closed branch of
`PubSubConnection.EncodeOutboundAsync` and the refused-send return of
`SendNetworkMessageAsync`, two of the three changed lines the
patch-coverage report of #4712 listed as uncovered. Test-only change.

Review: the test double `RecordingTransport` guards its recorded sends
with a private `System.Threading.Lock` and exposes `SendCount` and
`GetSendsSnapshot()`, so the tests no longer lock the list itself
(b7e0ba4).

### Validation

| Command | Result |
| --- | --- |
| `dotnet test tests/Opc.Ua.PubSub.Tests -f net10.0 --filter
"Name~JsonAnnouncementOfASecuredWriterGroupIsRefusedAsync"` with the
fail-closed branch of `EncodeOutboundAsync` temporarily removed | Fails:
`Expected: no item String containing "/metadata/"`, but the announcement
was published on `opcua/json/metadata/p17/7/42` |
| `dotnet test tests/Opc.Ua.PubSub.Tests -f net10.0` | 1499 passed, 1
skipped |
| `dotnet test tests/Opc.Ua.PubSub.Tests -f net48 --filter
FullyQualifiedName~MetaDataPublisherTests` | 10 passed |
| On the merged head with the lock change (07142f7):
`MetaDataPublisherTests` on net10.0 (3 runs) and net48; the full
`Opc.Ua.PubSub.Tests` on net10.0 | 10 passed each; 1498 passed, 2
skipped (the UDP multicast loopback tests, which skip when loopback is
not available) |

## Related Issues

- Follow-up to #4712.

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants