/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Buffers.Binary;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua.Bindings;
using Opc.Ua.Bindings.Pcap.Capture;
using Opc.Ua.Bindings.Pcap.Frame;
using Opc.Ua.Bindings.Pcap.KeyLog;
namespace Opc.Ua.Bindings.Pcap.Dissection
{
///
/// Reassembles decrypted OPC UA service calls from captured UA-SC
/// chunks.
///
public sealed class ServiceCallReassembler
{
///
/// Constructs a service-call reassembler with no-op logging.
///
public ServiceCallReassembler()
: this(NullLoggerFactory.Instance)
{
}
///
/// Constructs a service-call reassembler with explicit logging.
///
///
/// is null.
///
public ServiceCallReassembler(ILoggerFactory loggerFactory)
{
ArgumentNullException.ThrowIfNull(loggerFactory);
m_loggerFactory = loggerFactory;
m_logger = loggerFactory.CreateLogger();
m_messageContext = new ServiceMessageContext(
NoopTelemetryContext.Instance,
EncodeableFactory.Create());
}
///
/// Add (or update) the key material for a token observed in the
/// capture. Tokens are matched against incoming chunks by their
/// (channelId, tokenId). Must be called at least once for each
/// channel before chunks for that
/// channel can be decoded.
///
///
/// is null.
///
public void LoadKeyMaterial(ChannelKeyMaterial material)
{
ArgumentNullException.ThrowIfNull(material);
if (m_channels.TryGetValue(material.ChannelId, out OfflineSecureChannel? channel))
{
channel.LoadKeyMaterial(material);
return;
}
m_channels[material.ChannelId] = new OfflineSecureChannel(material, m_loggerFactory);
}
///
/// Feeds one captured OPC UA chunk through the reassembler. If a
/// request/response pair completes as a result of this chunk, the
/// completed is appended to the
/// internal result queue and returned by the next call to
/// .
///
///
/// The chunk to feed. The first 4 bytes of
/// must be a valid OPC UA message-type marker; otherwise the chunk is
/// silently skipped.
///
public void Push(CaptureFrame frame)
{
try
{
if (frame.Data.Length < sizeof(uint))
{
return;
}
ReadOnlySpan data = frame.Data.Span;
uint messageType = BinaryPrimitives.ReadUInt32LittleEndian(data);
if (!TcpMessageType.IsValid(messageType))
{
return;
}
if (frame.Direction == CaptureFrameDirection.Unknown)
{
m_logger.LogWarning(
"Skipping OPC UA chunk with unknown direction at {Timestamp}.",
frame.Timestamp);
return;
}
bool fromClient = frame.Direction == CaptureFrameDirection.ClientToServer;
uint baseType = messageType & TcpMessageType.MessageTypeMask;
switch (baseType)
{
case TcpMessageType.Hello:
AddControlMessage(frame, "Hello");
return;
case TcpMessageType.Acknowledge:
AddControlMessage(frame, "Acknowledge");
return;
case TcpMessageType.Error:
AddControlMessage(frame, "Error");
return;
case TcpMessageType.ReverseHello:
AddControlMessage(frame, "ReverseHello");
return;
case TcpMessageType.Open:
AddOpenSecureChannelMessage(frame, fromClient);
return;
case TcpMessageType.Message:
case TcpMessageType.Close:
PushSecureChannelChunk(frame, fromClient, baseType);
return;
}
}
catch (Exception ex)
{
m_logger.LogWarning(ex, "Skipping OPC UA chunk because service-call reassembly failed.");
}
}
///
/// Returns and clears the set of service calls that completed since
/// the last call. The reassembler keeps internal state for in-flight
/// requests until they complete or the channel closes.
///
public IReadOnlyList DrainCompleted()
{
DecodedServiceCall[] completed = m_completed.ToArray();
m_completed.Clear();
return completed;
}
///
/// Convenience helper: feeds every frame from an enumerable, then
/// returns all completed service calls.
///
///
/// is null.
///
public IReadOnlyList ProcessAll(IEnumerable frames)
{
ArgumentNullException.ThrowIfNull(frames);
foreach (CaptureFrame frame in frames)
{
Push(frame);
}
return DrainCompleted();
}
///
/// Same as but
/// for async streams (e.g. directly from
/// ).
///
///
/// is null.
///
public async ValueTask> ProcessAllAsync(
IAsyncEnumerable frames,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(frames);
await foreach (CaptureFrame frame in frames.WithCancellation(ct).ConfigureAwait(false))
{
Push(frame);
}
return DrainCompleted();
}
private void AddControlMessage(CaptureFrame frame, string name)
{
m_completed.Add(new DecodedServiceCall
{
RequestTimestamp = frame.Timestamp,
RequestName = name,
RequestBodySize = frame.Data.Length
});
}
private void AddOpenSecureChannelMessage(CaptureFrame frame, bool fromClient)
{
uint channelId = 0;
uint tokenId = 0;
if (frame.Data.Length >= 16)
{
ReadOnlySpan data = frame.Data.Span;
channelId = BinaryPrimitives.ReadUInt32LittleEndian(data[8..]);
tokenId = BinaryPrimitives.ReadUInt32LittleEndian(data[12..]);
}
m_completed.Add(new DecodedServiceCall
{
ChannelId = channelId,
TokenId = tokenId,
RequestTimestamp = frame.Timestamp,
RequestName = fromClient ? "OpenSecureChannelRequest" : "OpenSecureChannelResponse",
RequestBodySize = frame.Data.Length,
RequestSummary = "(asymmetric OpenSecureChannel chunk)"
});
}
private void PushSecureChannelChunk(CaptureFrame frame, bool fromClient, uint baseType)
{
try
{
if (frame.Data.Length < TcpMessageLimits.BaseHeaderSize)
{
return;
}
uint channelId = BinaryPrimitives.ReadUInt32LittleEndian(frame.Data.Span[8..]);
if (!m_channels.TryGetValue(channelId, out OfflineSecureChannel? channel))
{
m_logger.LogWarning(
"Skipping OPC UA chunk for channel 0x{ChannelId:X8}; no key material is loaded.",
channelId);
return;
}
OfflineDecodedChunk decoded = channel.ReadChunk(frame.Data.Span, fromClient);
PendingServiceCall pending = GetPendingCall(decoded, frame.Timestamp, fromClient);
if (fromClient)
{
pending.RequestChunks.Add(decoded.Body.ToArray());
pending.TokenId = decoded.TokenId;
if (decoded.IsFinal)
{
pending.RequestIsComplete = true;
pending.Request = DecodeMessage(pending.RequestChunks);
}
}
else
{
pending.ResponseChunks.Add(decoded.Body.ToArray());
pending.TokenId = decoded.TokenId;
if (decoded.IsFinal)
{
pending.ResponseTimestamp = frame.Timestamp;
pending.ResponseIsComplete = true;
pending.Response = DecodeMessage(pending.ResponseChunks);
}
}
if (pending.RequestIsComplete && pending.ResponseIsComplete)
{
m_completed.Add(CreateDecodedServiceCall(decoded, pending));
m_pending.Remove((decoded.ChannelId, decoded.RequestId));
}
if (baseType == TcpMessageType.Close && decoded.IsFinal)
{
RemoveChannelState(decoded.ChannelId);
}
}
catch (PcapDiagnosticsException ex)
{
m_logger.LogWarning(ex, "Skipping OPC UA secure-channel chunk because offline decoding failed.");
}
catch (Exception ex)
{
m_logger.LogWarning(ex, "Skipping OPC UA secure-channel chunk because reassembly failed.");
}
}
private PendingServiceCall GetPendingCall(
OfflineDecodedChunk decoded,
DateTimeOffset timestamp,
bool fromClient)
{
var key = (decoded.ChannelId, decoded.RequestId);
if (m_pending.TryGetValue(key, out PendingServiceCall? pending))
{
if (!fromClient || !pending.RequestIsComplete)
{
return pending;
}
}
pending = new PendingServiceCall(timestamp);
m_pending[key] = pending;
return pending;
}
private DecodedServiceCall CreateDecodedServiceCall(
OfflineDecodedChunk decoded,
PendingServiceCall pending)
{
Dictionary annotations = [];
AddRequestAnnotations(annotations, pending.Request?.Message);
return new DecodedServiceCall
{
ChannelId = decoded.ChannelId,
TokenId = pending.TokenId,
RequestId = decoded.RequestId,
RequestTimestamp = pending.RequestTimestamp,
ResponseTimestamp = pending.ResponseTimestamp,
RequestName = pending.Request?.Name,
ResponseName = pending.Response?.Name,
ResponseStatus = pending.Response?.ResponseStatus,
RequestBodySize = GetTotalLength(pending.RequestChunks),
ResponseBodySize = GetTotalLength(pending.ResponseChunks),
RequestSummary = pending.Request?.Summary,
ResponseSummary = pending.Response?.Summary,
Annotations = annotations
};
}
private DecodedMessage DecodeMessage(List chunks)
{
byte[] body = Concatenate(chunks);
string undecodedName = $"{body.Length}B";
try
{
using var decoder = new BinaryDecoder(body, m_messageContext);
ExpandedNodeId typeId = decoder.ReadExpandedNodeId(null);
undecodedName = typeId.ToString();
if (!m_messageContext.Factory.TryGetEncodeableType(
typeId,
out IEncodeableType? encodeableType))
{
return DecodedMessage.Undecoded(undecodedName, body.Length);
}
IEncodeable message = encodeableType.CreateInstance();
message.Decode(decoder);
return CreateDecodedMessage(message, body.Length);
}
catch (Exception ex)
{
m_logger.LogWarning(ex, "Unable to decode OPC UA service message body.");
return DecodedMessage.Undecoded(undecodedName, body.Length);
}
}
private DecodedMessage CreateDecodedMessage(IEncodeable message, int byteCount)
{
string name = message.GetType().Name;
if (message is IServiceRequest request)
{
return new DecodedMessage(name, CreateRequestSummary(request, name, byteCount), null, request);
}
if (message is IServiceResponse response)
{
StatusCode status = response.ResponseHeader.ServiceResult;
string summary = $"status={status} ts={response.ResponseHeader.Timestamp:O}";
return new DecodedMessage(name, summary, status, response);
}
return new DecodedMessage(name, $"{name} body={byteCount}B", null, message);
}
private static string CreateRequestSummary(IServiceRequest request, string typeName, int byteCount)
{
string detail = CreateKnownRequestDetail(request);
string header = request.RequestHeader == null
? string.Empty
: $"handle={request.RequestHeader.RequestHandle} audit={request.RequestHeader.AuditEntryId ?? string.Empty}";
if (detail.Length == 0)
{
detail = $"{typeName} body={byteCount}B";
}
return header.Length == 0 ? detail : $"{header} {detail}";
}
private static string CreateKnownRequestDetail(IServiceRequest request)
{
string typeName = request.GetType().Name;
return typeName switch
{
"ReadRequest" => FormatCountAndValue(
request,
"NodesToRead",
"nodes",
"MaxAge",
"maxAge"),
"BrowseRequest" => FormatCountAndValue(
request,
"NodesToBrowse",
"nodes",
"View",
"view"),
"WriteRequest" => FormatCount(request, "NodesToWrite", "nodes"),
"CallRequest" => FormatCount(request, "MethodsToCall", "methods"),
"HistoryReadRequest" => FormatCount(request, "NodesToRead", "nodes"),
"CreateMonitoredItemsRequest" => FormatCount(request, "ItemsToCreate", "items"),
"DeleteMonitoredItemsRequest" => FormatCount(request, "MonitoredItemIds", "items"),
"PublishRequest" => FormatCount(request, "SubscriptionAcknowledgements", "acks"),
_ => string.Empty
};
}
private static string FormatCount(
IEncodeable message,
string propertyName,
string itemName)
{
int? count = GetCollectionCount(message, propertyName);
return count.HasValue ? $"{count.Value} {itemName}" : string.Empty;
}
private static string FormatCountAndValue(
IEncodeable message,
string countPropertyName,
string itemName,
string valuePropertyName,
string valueName)
{
int? count = GetCollectionCount(message, countPropertyName);
string? value = GetPropertyString(message, valuePropertyName);
if (!count.HasValue)
{
return string.Empty;
}
return value == null
? $"{count.Value} {itemName}, {valueName}=null"
: $"{count.Value} {itemName}, {valueName}={value}";
}
private static int? GetCollectionCount(IEncodeable message, string propertyName)
{
object? value = message.GetType().GetProperty(propertyName)?.GetValue(message);
return value switch
{
ICollection collection => collection.Count,
_ => null
};
}
private static string? GetPropertyString(IEncodeable message, string propertyName)
{
object? value = message.GetType().GetProperty(propertyName)?.GetValue(message);
return value?.ToString();
}
private static void AddRequestAnnotations(
Dictionary annotations,
IEncodeable? message)
{
if (message is not IServiceRequest request || request.RequestHeader == null)
{
return;
}
annotations["RequestHandle"] = request.RequestHeader.RequestHandle.ToString(CultureInfo.InvariantCulture);
annotations["AuditEntryId"] = request.RequestHeader.AuditEntryId;
}
private static byte[] Concatenate(List chunks)
{
byte[] result = new byte[GetTotalLength(chunks)];
int offset = 0;
foreach (byte[] chunk in chunks)
{
chunk.AsSpan().CopyTo(result.AsSpan(offset));
offset += chunk.Length;
}
return result;
}
private static int GetTotalLength(List chunks)
{
int length = 0;
foreach (byte[] chunk in chunks)
{
length += chunk.Length;
}
return length;
}
private void RemoveChannelState(uint channelId)
{
List<(uint channelId, uint requestId)> removeKeys = [];
foreach ((uint pendingChannelId, uint pendingRequestId) in m_pending.Keys)
{
if (pendingChannelId == channelId)
{
removeKeys.Add((pendingChannelId, pendingRequestId));
}
}
foreach ((uint pendingChannelId, uint pendingRequestId) in removeKeys)
{
m_pending.Remove((pendingChannelId, pendingRequestId));
}
if (m_channels.Remove(channelId, out OfflineSecureChannel? channel))
{
channel.Dispose();
}
}
private sealed class PendingServiceCall
{
public PendingServiceCall(DateTimeOffset requestTimestamp)
{
RequestTimestamp = requestTimestamp;
}
public DateTimeOffset RequestTimestamp { get; }
public DateTimeOffset? ResponseTimestamp { get; set; }
public List RequestChunks { get; } = [];
public List ResponseChunks { get; } = [];
public bool RequestIsComplete { get; set; }
public bool ResponseIsComplete { get; set; }
public uint TokenId { get; set; }
public DecodedMessage? Request { get; set; }
public DecodedMessage? Response { get; set; }
}
private readonly struct DecodedMessage
{
public DecodedMessage(
string? name,
string? summary,
StatusCode? responseStatus,
IEncodeable? message)
{
Name = name;
Summary = summary;
ResponseStatus = responseStatus;
Message = message;
}
public string? Name { get; }
public string? Summary { get; }
public StatusCode? ResponseStatus { get; }
public IEncodeable? Message { get; }
public static DecodedMessage Undecoded(string name, int byteCount)
{
return new DecodedMessage(name, $"(undecoded: {byteCount} bytes)", null, null);
}
}
///
/// Minimal implementation that
/// hands out for everything.
///
private sealed class NoopTelemetryContext : ITelemetryContext
{
public static NoopTelemetryContext Instance { get; } = new();
public ILoggerFactory LoggerFactory => NullLoggerFactory.Instance;
public ActivitySource ActivitySource => s_activitySource;
public Meter CreateMeter()
{
return new Meter("Opc.Ua.Bindings.Pcap.ServiceCallReassembler");
}
private NoopTelemetryContext()
{
}
private static readonly ActivitySource s_activitySource
= new("Opc.Ua.Bindings.Pcap.ServiceCallReassembler");
}
private readonly Dictionary m_channels = [];
private readonly Dictionary<(uint channelId, uint requestId), PendingServiceCall> m_pending = [];
private readonly List m_completed = [];
private readonly ServiceMessageContext m_messageContext;
private readonly ILoggerFactory m_loggerFactory;
private readonly ILogger m_logger;
}
}