19 KiB
OPC UA Packet Capture, Dissection, and Replay
The OPC UA packet-capture feature records UA traffic, stores the secure-channel keys needed for offline decoding, reconstructs service calls, and replays captured conversations. The reusable engine ships as OPCFoundation.NetStandard.Opc.Ua.Bindings.Pcap; the OPC UA MCP server exposes it as capture, decode, and replay tools. Decoding follows OPC UA Part 6 secure conversation framing and reuses the stack UaSCUaBinaryChannel path instead of reimplementing cryptography.
Security model
This binding is a diagnostics-only tool. It captures live OPC UA traffic and writes both the raw frames and the symmetric channel keys (signing key, encryption key, IVs, nonces) to disk so that the traffic can be decrypted offline. Do not enable in production. Operators who enable any part of this binding should treat every output file as a secret.
Defaults — opt-in everything
| Capability | Default | Opt-in mechanism |
|---|---|---|
Frame capture (start_capture) |
available | PcapBindings.Install() or AddOpcUaBindingsPcap() |
| Keylog extraction | available when capture runs | implicit when capture is active |
MCP dump_keys / decode_pcap_with_keys / replay_pcap |
off | PcapOptions.EnableDiagnosticsTools = true or env OPCUA_PCAP_ENABLE_DIAGNOSTICS=1 |
| Mock-client replay | off | PcapOptions.AllowMockClientReplay = true AND populate PcapOptions.AllowedReplayEndpoints |
| Tamper-evident audit log | off | PcapOptions.EnableTamperEvidentAudit = true |
| KMS-backed key escrow (instead of disk) | not registered | register your own IKeyEscrowProvider in DI |
When any diagnostic tool is enabled the host emits a Warning-level log
line at startup so the choice is observable in production logs.
File-system posture
| Artifact | Mode (Unix) | Encryption-at-rest | Rotation |
|---|---|---|---|
*.pcap capture |
0600 |
none (binary frames; key material lives elsewhere) | MaxBytesPerCapture (default 256 MB), then .001.pcap etc. |
*.keylog.json / *.keylog.txt |
0600 |
AES-256-GCM when constructed with a session key | MaxBytesPerKeylog (default 8 MB) |
*.keylog.json.key (session key) |
0600 |
n/a | rotated alongside keylog |
*.audit.jsonl (tamper-evident audit) |
0640 |
none, but hash-chained per line | append-only |
MaxArtifactsPerSession (default 16) prunes the oldest rotated files
when exceeded.
Default storage
The base folder defaults to a per-user data directory rather than a
shared /tmp directory:
- Linux:
~/.local/share/OPCFoundation/opcua-pcap - macOS:
~/Library/Application Support/OPCFoundation/opcua-pcap - Windows:
%LOCALAPPDATA%\OPCFoundation\opcua-pcap
The base folder is created with mode 0700 on Unix and inherits the
user profile ACL on Windows. Override via PcapOptions.BaseFolder.
Audit events
When IPcapAuditSink is registered (default: LoggerPcapAuditSink),
every security-relevant operation emits a structured event:
StartCapture,StopCaptureDumpKeys,DecodePcapWithKeysStartReplay,StopReplayFrameCaptured— rate-limited to 1/min per channel
For tamper-evident storage register the optional
HashChainedAuditFileSink (writes a JSONL ledger with per-line HMACs).
Operator checklist
Before enabling any part of this binding in a non-development environment:
- Confirm the host process runs under a dedicated service account with no shell access. Keylog files are 0600; only that account can read them.
- Confirm
PcapOptions.BaseFolderpoints to a per-user / per-service directory (NOT/tmpor any shared directory). - Confirm
EnableDiagnosticsToolsis set explicitly (not defaulted) and that the MCP transport is authenticated (Bearer token / mutual TLS) and audited. - If using replay, populate
AllowedReplayEndpointswith the minimum set of hostnames required. Never use a wildcard. - If using a KMS, register
IKeyEscrowProviderand verify the disk writer is no longer invoked. - Subscribe to upstream CVE feeds for
SharpPcapandPacketDotNet(see the Dependencies and governance section near the bottom of this document).
Out-of-scope (known limitations)
This binding does not yet satisfy a strong, production-grade security bar prescription for handling secret material. The known gaps below are acknowledged limitations that consumers must mitigate operationally or accept as residual risk.
-
In-process keylog writer.
UaKeyLog{Json,Text}Writerand the per-session AES key live in the same AppDomain as the host application. A host-process compromise (e.g., an unrelated vulnerability in the OPC UA Client) can read keys directly from process memory before the encrypt-at-rest path runs. Mitigation options:- Run the host under a dedicated service account with no
shell access so a compromise can't pivot. The keylog files
are already mode
0600and confined to the per-user data directory; the service account boundary is the missing piece. - Register a custom
IKeyEscrowProviderthat hands keys to a KMS (e.g., Azure Key Vault HSM) instead of the disk writer. The disk writer is then unused. - A separate-process keylog writer is on the roadmap; until then, treat option 1 or 2 as required for any non-developer deployment.
- Run the host under a dedicated service account with no
shell access so a compromise can't pivot. The keylog files
are already mode
-
SuppressNonceValidationErrorsis unchanged. This OPC UA Client configuration option remains as a footgun for misconfigured deployments. Keep the default (false) and add telemetry if you must override it. -
Native dependencies on libpcap / Npcap.
SharpPcapandPacketDotNetare unmanaged native loads (see the Dependencies and governance section). Subscribe to upstream CVE feeds and bump within 30 days for HIGH or CRITICAL advisories.
When to use it
- Debug OPC UA interoperability issues that only appear on a real wire.
- Capture a repro for a bug that cannot be reproduced under a debugger.
- Validate wire-level effects of stack, application, or security-policy changes.
- Build decoder and replay tests from real captures.
Architecture
graph TD
A[Capture source] --> B[pcap / pcapng file]
A --> C[keylog file]
B --> D[Offline decoder]
C --> D
D --> E[UaSCUaBinaryChannel decoding path]
E --> F[Service-call timeline]
F --> G[Formatters]
F --> H[Replay engines]
Integration with the central channel manager
The Pcap binding composes with the central IClientChannelManager introduced in issue #3288 via the global TransportBindings.Channels registry: AddOpcUaBindingsPcap installs a PcapTransportChannelBinding decorator over the TCP channel factory, and ClientChannelManager — which by default reads from that same global registry — picks the wrapped factory up automatically. There is no extra wiring code; the composition is pure layering at the transport binding level.
Three properties of the channel manager flow through to capture for free:
- Sharing. When multiple participants share a single
IManagedTransportChannel(the refcount + key-equivalence model inManagedChannelKey), the underlyingCapturingMessageSocketrecords all participant traffic onto a single wire stream. One capture session covers aSession, aDiscoveryClient, and aRegistrationClientall targeting the same endpoint. - Transparent reconnect. During the manager's coalesced reconnect cycle the wrapping socket is re-created against the new transport but the
IChannelCaptureRegistrykeeps recording into the same session file — the reconnect transition appears in the timeline as a state edge rather than a capture interruption. - Faulted-entry swap. The
Phase ESwapFaultedEntryAsyncpath produces a freshChannelEntryunder the sameManagedChannelKey; the active capture observer rolls onto the new entry so an exhaustion-then-recover cycle is recorded end-to-end in one session.
If you compose the channel manager and the capture binding via DI the standard registration order is:
services.AddOpcUa().AddClient(options => { }); // central IClientChannelManager
services.AddSingleton<OpcUaSessionManager>(); // your application services
services.AddOpcUaBindingsPcap(); // capture binding + CaptureSessionManager
services.AddOpcUaBindingsPcapFormatters(); // service-timeline / pcap / pcapng / json / csv / text
services.AddOpcUaBindingsPcapReplay(); // mock-client / mock-server replay engines
Registration order does not strictly matter (the Pcap binding installs synchronously into the process-wide registry), but the order above reads top-down as "register the channel manager, then your services, then opt in to capture". For non-DI consumers, call PcapBindings.Install() at startup to achieve the same effect.
Capture sources
NIC (nic) captures live traffic through SharpPcap. It requires libpcap on Linux/macOS or Npcap on Windows and can use interface names and BPF filters.
In-process client (inproc-client) taps OPC UA client channels in the current process. It uses IFrameCaptureSink for wire chunks and client token activation for key material. No native driver is required.
In-process server (inproc-server) passively taps channels accepted by a hosted OPC UA server. It uses the same frame sink plus TcpListenerChannel.OnTokenActivated. No native driver is required.
Replay (replay) reads an existing pcap or pcapng plus .uakeys.json or .uakeys.txt. Use it for offline decode, summaries, and mock-client or mock-server replay.
Quick start: in-process client capture
services.AddOpcUa().AddClient(options => { }); // central IClientChannelManager
services.AddOpcUaBindingsPcap(); // capture binding + CaptureSessionManager
CaptureSessionManager manager = serviceProvider.GetRequiredService<CaptureSessionManager>();
var session = await manager.StartAsync(new StartCaptureRequest
{
Source = CaptureSourceKind.InProcessClient,
MaxDurationSeconds = 60,
SessionFolder = "C:/captures"
}, ct);
// Do real OPC UA work here — every channel created through the central
// IClientChannelManager (Session.CreateAsync, ManagedSessionBuilder,
// DiscoveryClient, RegistrationClient, GDS clients, …) is automatically
// wrapped by the Pcap binding. Channel sharing means a single recording
// stream covers every participant on the same endpoint.
await manager.StopAsync(session.SessionId, ct);
var bytes = await manager.GetCaptureAsync(
session.SessionId,
FormatKind.ServiceTimeline,
ct);
Quick start: replay and decode an existing pcap
services.AddOpcUaBindingsPcap();
CaptureSessionManager manager = serviceProvider.GetRequiredService<CaptureSessionManager>();
var session = await manager.StartAsync(new StartCaptureRequest
{
Source = CaptureSourceKind.Replay,
PcapFilePath = "C:/captures/issue-1423.pcap",
KeyLogFilePath = "C:/captures/issue-1423.uakeys.json",
SessionFolder = "C:/captures"
}, ct);
var timeline = await manager.GetCaptureAsync(
session.SessionId,
FormatKind.ServiceTimeline,
ct);
Quick start: mock-server replay
services.AddOpcUaBindingsPcap();
CaptureSessionManager manager = serviceProvider.GetRequiredService<CaptureSessionManager>();
var replay = await manager.ReplayAsync(new ReplayPcapRequest
{
PcapFilePath = "C:/captures/server-conversation.pcap",
KeyLogFilePath = "C:/captures/server-conversation.uakeys.json",
Mode = ReplayMode.MockServer,
ListenEndpointUrl = "opc.tcp://localhost:4840/CapturedServer"
}, ct);
Security profile coverage
Every stack-supported profile can be decoded because the offline decoder reuses UaSCUaBinaryChannel, ChannelToken, and the stack cryptography helpers:
Basic128Rsa15Basic256Basic256Sha256Aes128_Sha256_RsaOaepAes256_Sha256_RsaPssRSA_DH_AesGcmRSA_DH_ChaChaPolyECC_nistP256,ECC_nistP256_AesGcm,ECC_nistP256_ChaChaPolyECC_nistP384,ECC_nistP384_AesGcm,ECC_nistP384_ChaChaPolyECC_brainpoolP256r1,ECC_brainpoolP256r1_AesGcm,ECC_brainpoolP256r1_ChaChaPolyECC_brainpoolP384r1,ECC_brainpoolP384r1_AesGcm,ECC_brainpoolP384r1_ChaChaPolyECC_curve25519,ECC_curve25519_AesGcm,ECC_curve25519_ChaChaPoly
Transport coverage
opc.tcp: full capture, decode, and replay support.opc.tcpreverse connect: full support; the TCP stream is decoded through the same Part 6 framing.opc.https: best-effort. The pcap can be captured, but decoding encrypted TLS payloads requires an external TLS keylog such asSSLKEYLOGFILE.
Storage
See Security model > Default storage.
File format reference
The library writes two artifacts per session: a pcap of the captured chunks and a keylog of the activated tokens.
.uakeys.json format
.uakeys.json is JSON Lines (JSONL): one UTF-8 JSON object per line. Each object describes one activated OPC UA secure-channel token.
| Field | Type | Meaning |
|---|---|---|
channelId |
uint32 |
OPC UA secure-channel id. |
tokenId |
uint32 |
Secure-channel token id used in symmetric chunks. |
securityPolicyUri |
string |
OPC UA security policy URI. |
securityMode |
string |
One of None, Sign, or SignAndEncrypt. |
createdAt |
string |
Token creation time as ISO-8601 UTC. |
lifetimeMs |
int |
Token lifetime in milliseconds. |
clientNonce |
base64 string |
Client nonce for key derivation. |
serverNonce |
base64 string |
Server nonce for key derivation. |
clientSigningKey |
base64 string |
Client-to-server signing key; omitted for None. |
clientEncryptingKey |
base64 string |
Client-to-server encryption key; omitted for None. |
clientInitializationVector |
base64 string |
Client-to-server IV; omitted for None. |
serverSigningKey |
base64 string |
Server-to-client signing key; omitted for None. |
serverEncryptingKey |
base64 string |
Server-to-client encryption key; omitted for None. |
serverInitializationVector |
base64 string |
Server-to-client IV; omitted for None. |
Worked example:
{"channelId":1001,"tokenId":7,"securityPolicyUri":"http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256","securityMode":"SignAndEncrypt","createdAt":"2026-06-06T11:48:00Z","lifetimeMs":3600000,"clientNonce":"Y2xpZW50","serverNonce":"c2VydmVy","clientSigningKey":"MDEyMw==","clientEncryptingKey":"NDU2Nw==","clientInitializationVector":"ODlhYg==","serverSigningKey":"Y2RlZg==","serverEncryptingKey":"MDEyMw==","serverInitializationVector":"NDU2Nw=="}
For SecurityMode=None, key fields are omitted:
{"channelId":1002,"tokenId":1,"securityPolicyUri":"http://opcfoundation.org/UA/SecurityPolicy#None","securityMode":"None","createdAt":"2026-06-06T11:49:00Z","lifetimeMs":3600000,"clientNonce":"","serverNonce":""}
.uakeys.txt format
.uakeys.txt is a Wireshark-style, single-line keylog format. The file begins with this header:
# OPC UA channel key log v1
Each record uses a single space as the field separator:
OPCUA_CHANNEL <channelId-hex> <tokenId-hex> <securityPolicyUri> <securityMode> <client_signing_hex> <client_encrypting_hex> <client_iv_hex> <server_signing_hex> <server_encrypting_hex> <server_iv_hex>
Hex values are uppercase with no separators. - denotes a null or empty field. Lines beginning with # are comments.
Worked example:
# OPC UA channel key log v1
OPCUA_CHANNEL 000003E9 00000007 http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256 SignAndEncrypt 30313233 34353637 38396162 63646566 30313233 34353637
OPCUA_CHANNEL 000003EA 00000001 http://opcfoundation.org/UA/SecurityPolicy#None None - - - - - -
PCAP file format
Produced .pcap files use standard libpcap and are compatible with Wireshark and other readers.
NIC captures preserve real link-layer and IP/TCP headers. In-process taps write BSD-loopback records (link_type=0) and synthesize IP/TCP headers around each OPC UA chunk so dissectors see TCP traffic while the UA Secure Conversation chunk bytes remain exact.
For synthesized in-process captures:
- Client endpoint:
127.0.1.x - Server endpoint:
127.0.2.x x = channelId & 0xFF- Server port:
4840 - Client port:
49152 + (channelId & 0x3FFF)
The synthesized addresses are deterministic local analysis identifiers, not real endpoints.
Wireshark interop
Wireshark's bundled OPC UA dissector can read the generated pcap files and understands HEL, ACK, OPN, MSG, and CLO framing. It cannot decrypt encrypted MSG or CLO chunks because it does not know the OPC UA symmetric keys.
The .uakeys.txt format is line-oriented and Wireshark-style so a future Lua plugin can:
- Load
# OPC UA channel key log v1files. - Index records by
channelIdandtokenId. - Match OPC UA symmetric chunks to a token id.
- Pass the matching signing, encrypting, and IV material to the decoder.
This only defines the future plugin format; no delivery date is promised.
Dependencies and governance
The Pcap binding has two native-code transitive dependencies:
| Package | Native binding | Upstream advisories |
|---|---|---|
PacketDotNet |
Wireshark-style packet decode | https://github.com/dotpcap/packetnet/security/advisories |
SharpPcap |
libpcap / Npcap capture |
https://github.com/dotpcap/sharppcap/security/advisories |
Both are pinned in Directory.Packages.props and tracked in the project SBOM. Operators deploying this binding should subscribe to the upstream advisory feeds and review at least monthly. HIGH or CRITICAL CVEs should be addressed within 30 days.
Run dotnet list package --vulnerable --include-transitive against Stack/Opc.Ua.Bindings.Pcap/Opc.Ua.Bindings.Pcap.csproj in your CI pipeline; the project does this as part of its release gate.
Security considerations
Keylog files are secrets. See Security model at the top of this document for the full posture.
Limitations
- The stack does not produce live TLS keylogs for
opc.https; provide an external TLS keylog when TLS decryption is required. - PubSub UDP and MQTT capture are out of scope for this feature.
- A Wireshark Lua plugin is not included. The text keylog format is documented so a plugin can be added later.
Troubleshooting
list_interfacesreturns empty: install libpcap or Npcap and ensure the process has permission to enumerate adapters.decode_pcap_with_keysreturns 0 service calls: verify the pcap and keylog are from the same capture and include the same channel and token ids.- Frames are captured but keys are missing: start the in-process capture before opening the secure channel so token activation is observed.