A lightweight, from-scratch OPC UA client stack for .NET 8

TinyUa implements the complete OPC UA binary protocol stack from scratch — binary encoding, four security policies, message chunking, secure channels, and a high-level client with a Fluent API. The core libraries have zero dependency on the OPC Foundation SDK. All cryptography uses only System.Security.Cryptography from .NET 8.

Why TinyUa?

  • Zero OPCF SDK dependency in core libraries — fully self-contained binary protocol implementation
  • Lightweight — the core stack (Core + Transport + Client) has no external NuGet dependencies
  • Modern .NET — targets .NET 8 with nullable reference types and async/await throughout
  • Fluent API — intuitive builder pattern for connecting, configuring security, and executing operations
  • Comprehensive security — 4 security policies, 3 user token types, auto-generated certificates, auto-discovery of server certificates
  • Production-ready features — automatic reconnect with exponential backoff, bounded subscription dispatch with backpressure, keep-alive monitoring

Features

Protocol (from scratch — OPC UA Part 6 binary protocol)

  • Binary encoding/decoding of all OPC UA built-in types
  • Message chunking with sign-then-encrypt / verify-before-decrypt pipeline
  • Secure channel lifecycle management (Open/Renew/Close)
  • Token rotation with current/next/previous ChannelSecurityToken tracking
  • PSHA-256 key derivation for symmetric channel keys

Security

Policy Key Transport Symmetric Asymmetric Signature
None
Basic256Sha256 RSA-OAEP-SHA1 AES-256-CBC + HMAC-SHA256 RSA-SHA256
Aes128Sha256RsaOaep RSA-OAEP-SHA1 AES-128-CBC + HMAC-SHA256 RSA-SHA256
Aes256Sha256RsaPss RSA-OAEP-SHA256 AES-256-CBC + HMAC-SHA256 RSA-PSS-SHA256

User identity tokens: Anonymous, UserName, X509 Certificate

Certificate management:

  • Auto-generated self-signed client certificates at runtime
  • Auto-discovery of server certificates via GetEndpoints service
  • Optional built-in server certificate validity, EKU, and chain-integrity validation
  • DPAPI-encrypted credential persistence (WPF Explorer)

Client

  • Fluent Builder API: UaClient.ConnectTo(url).WithSecurity(...).WithUserName(user, pass).BuildAndRunAsync()
  • Services: Read, Write, Browse, BrowseNext, CreateSession, ActivateSession, Subscribe, Publish
  • Subscriptions: Session-level Publish credit window plus bounded, ordered per-subscription callback queues with configurable backpressure/drop policy
  • Reconnect: Single-flight automatic reconnect with exponential backoff, session/subscription recovery, and keep-alive monitoring
  • State tracking: ClientState events are emitted only for real state transitions; reconnect progress and subscription recovery have separate events
  • Error handling: Configurable ErrorMode (Throw or ReturnNull)

Tools

  • WPF Explorer — Graphical OPC UA browser with security endpoint discovery and credential persistence
  • Console Example — Self-test mode with in-process OPCF reference server
  • Benchmarks — latency, security, live KEPServer correctness, subscription stress, and continuous memory/pool monitoring

Quick Start

Install via NuGet

TinyUa is available as three NuGet packages on nuget.org:

Package Description
TinyUa.Client Full OPC UA client — includes Core + Transport as transitive dependencies
TinyUa.Transport Message chunking and secure channel layer — depends on Core
TinyUa.Core Binary encoding, type system, and security policies — zero external deps

For most users, just install TinyUa.Client — it pulls in the other two automatically:

dotnet add package TinyUa.Client

Prerequisites

Build

git clone https://github.com/renrouputao/tinyua.git
cd tinyua
dotnet build TinyUa.sln

Run the self-test

dotnet run --project TinyUa.Example -- selftest

This starts an in-process OPC UA server and runs the client against it — no external server needed.

Minimal code example

using TinyUa.Client;

// Connect (no security, anonymous)
await using var client = await UaClient
    .ConnectTo("opc.tcp://myserver:4840")
    .WithAppName("MyApp")
    .BuildAndRunAsync();

// Simple read
var time = await client.ReadValueAsync<DateTime>("i=2258");
Console.WriteLine($"ServerTime: {time:HH:mm:ss.fff}");

// Simple write
var result = await client.WriteAsync("ns=2;s=Demo.Static.Scalar.Double", 3.14);
Console.WriteLine(result?.StatusCode.IsGood == true
    ? "OK"
    : $"Failed: {result?.StatusCode.GetStatusText() ?? "no result"}");
using TinyUa.Client;
using TinyUa.Core.Security;

// Connect with security and an existing PFX that contains its private key.
// Import the corresponding public certificate into the server's trusted-client store.
await using var client = await UaClient
    .ConnectTo("opc.tcp://myserver:4840")
    .WithSecurity("Basic256Sha256")
    .WithSecurity(opts =>
    {
        opts.Certificate = new CertificateOptions
        {
            CertificatePath = @"D:\Certs\myclient.pfx",
            PrivateKeyPassword = "mypassword",   // optional
            AutoGenerate = false
        };
    })
    .BuildAndRunAsync();

var time = await client.ReadValueAsync<DateTime>("i=2258");
Console.WriteLine($"ServerTime: {time:HH:mm:ss.fff}");

To create and persist a certificate on first use, set AutoGenerate = true and point CertificatePath at a writable .pfx path whose parent directory already exists. TinyUa also writes a .der file next to it for importing into the server. Reuse the PFX on later connections.

using TinyUa.Client;
using TinyUa.Core.Types;

// Connect with security + username
await using var client = await UaClient
    .ConnectTo("opc.tcp://myserver:4840")
    .WithAppName("MyApp")
    .WithSecurity("Basic256Sha256")
    .WithUserName("test", "user123")
    .BuildAndRunAsync();

// Batch read — each result carries its NodeId, StatusCode, and DataValue
var results = await client.ReadAsync(new NodeId[] { "i=2256", "i=2258" })
    ?? throw new InvalidOperationException("Read returned no results.");
foreach (var r in results)
{
    if (r.StatusCode.IsGood)
        Console.WriteLine($"  {r.NodeId} = {r.DataValue?.Value?.Value}");
    else
        Console.WriteLine($"  {r.NodeId}: {r.StatusCode.GetStatusText()}");
}
using TinyUa.Client;
using TinyUa.Client.Services;
using TinyUa.Core.Types;

// Batch write — each result carries its NodeId and StatusCode
await using var client = await UaClient
    .ConnectTo("opc.tcp://myserver:4840")
    .BuildAndRunAsync();

var writeResults = await client.WriteAsync(new WriteValue[]
{
    new() { NodeId = "ns=2;s=Demo.Static.Scalar.Double", Value = new DataValue(3.14) },
    new() { NodeId = "ns=2;s=Demo.Static.Scalar.Float",  Value = new DataValue(2.718f) },
    new() { NodeId = "ns=2;s=Demo.Static.Scalar.Int32",  Value = new DataValue(42) },
}) ?? throw new InvalidOperationException("Write returned no results.");
foreach (var r in writeResults)
{
    if (r.StatusCode.IsGood)
        Console.WriteLine($"  {r.NodeId}: OK");
    else
        Console.WriteLine($"  {r.NodeId}: {r.StatusCode.GetStatusText()}");
}
using TinyUa.Client;
using TinyUa.Client.Subscriptions;

// Subscribe — typed callback (single node)
var sub = await client.SubscribeAsync<DateTime>("i=2258", val =>
{
    Console.WriteLine($"ServerTime: {val:HH:mm:ss.fff}");
}, interval: 500);

await Task.Delay(3000);
sub.Dispose();
using TinyUa.Core.Types;

// Subscribe — batch with NodeId in callback
var sub = await client.SubscribeAsync(
    new NodeId[] { "i=2258", "ns=2;s=Demo.Dynamic.Scalar.Double" },
    (nodeId, value, status) =>
    {
        if (status.IsGood)
            Console.WriteLine($"{nodeId}: {value}");
    },
    interval: 500);

await Task.Delay(3000);
sub.Dispose();

Subscription callbacks are serialized in arrival order within one subscription. Different subscriptions have independent workers. Configure the bounded dispatch queue on the client:

using TinyUa.Client;
using TinyUa.Client.Subscriptions;

await using var client = await UaClient
    .ConnectTo("opc.tcp://myserver:4840")
    .WithSubscriptionDispatch(options =>
    {
        options.QueueCapacity = 64; // queued Publish responses, not individual values
        options.OverflowPolicy = NotificationOverflowPolicy.Wait;
    })
    .BuildAndRunAsync();

var sub = await client.SubscribeAsync<double>(
    "ns=2;s=Demo.Dynamic.Scalar.Double",
    (value, status) => Console.WriteLine($"{status.GetStatusText()}: {value}"),
    interval: 100);

Console.WriteLine($"pending={sub.PendingNotificationMessages}, dropped={sub.DroppedNotificationMessages}");

Wait is the lossless default: a slow callback eventually slows the finite Publish-request window. DropOldest favors fresh data and DropNewest preserves the existing backlog; both increment DroppedNotificationMessages.

// Browse — get child references from the Objects folder
var results = await client.BrowseAsync("i=85")
    ?? throw new InvalidOperationException("Browse returned no results.");
foreach (var desc in results[0].References ?? [])
    Console.WriteLine($"  {desc.DisplayName?.Text} ({desc.NodeClass}): {desc.NodeId}");

// BrowseNext — continue if there are more results
var continuationPoint = results[0].ContinuationPoint;
if (continuationPoint is { Length: > 0 })
{
    var next = await client.BrowseNextAsync(continuationPoint)
        ?? throw new InvalidOperationException("BrowseNext returned no results.");
    foreach (var desc in next[0].References ?? [])
        Console.WriteLine($"  {desc.DisplayName?.Text}: {desc.NodeId}");
}

For connection-state and subscription-recovery events, attach handlers before RunAsync:

using TinyUa.Client;

await using var client = UaClient
    .ConnectTo("opc.tcp://myserver:4840")
    .WithReconnectRetries(-1)
    .Build();

client.StateChanged += state => Console.WriteLine($"state={state}");
client.ReconnectBackoff += (attempt, delayMs) =>
    Console.WriteLine($"reconnect attempt={attempt}, retry in {delayMs} ms");
client.SubscriptionsRecovered += lossless =>
    Console.WriteLine(lossless ? "subscriptions recovered losslessly" : "subscriptions rebuilt; data may be missing");

await client.RunAsync();

StateChanged is emitted only when the state actually changes. Concurrent socket and request failure signals share one reconnect operation and do not produce duplicate Reconnecting state notifications.

Project Structure

Project Type Description
TinyUa.Core Library Binary encoding, OPC UA type system, 4 security policies, logging
TinyUa.Transport Library Message chunking, secure channel, key derivation (PSHA-256)
TinyUa.Client Library Fluent API, connection management, 10+ services, subscriptions, reconnect
TinyUa.Example Console (.exe) Self-test and example scenarios with in-process OPCF server
TinyUa.Explorer WPF (.exe) GUI browser with security endpoint discovery and certificate management
TinyUa.CertGen WPF (.exe) Client certificate generator
TinyUa.Benchmarks Console Latency, correctness, subscription stress, memory/GC, and crypto benchmarks
TinyUa.Client.Tests xUnit Client concurrency and robustness tests
TinyUa.Security.Tests xUnit Security integration tests against OPCF reference server

Dependency Graph

TinyUa.Core  ─────────────  (zero external deps — pure .NET 8)
  ├─ TinyUa.CertGen      ──  (references Core)
  └─ TinyUa.Transport  ───  (references Core)
       └─ TinyUa.Client  ──  (references Transport)
            ├─ TinyUa.Example      (+ OPCF SDK for in-process server)
            ├─ TinyUa.Explorer     (+ WPF, CommunityToolkit.Mvvm, WPF-UI)
            ├─ TinyUa.Benchmarks   (+ BenchmarkDotNet)
            ├─ TinyUa.Client.Tests
            └─ TinyUa.Security.Tests (+ OPCF SDK)

The OPC Foundation SDK (OPCFoundation.NetStandard.Opc.Ua) is used only by TinyUa.Example and TinyUa.Security.Tests to host an in-process reference server. Core, Transport, Client, Explorer, CertGen, Client.Tests, and Benchmarks do not depend on it.

Configuration

using TinyUa.Client;
using TinyUa.Client.Subscriptions;
using TinyUa.Core.Security;

var options = new UaClientOptions
{
    ApplicationName = "MyApp",
    Timeout = 30000,              // request timeout in ms
    SessionTimeout = 3600000,     // session lifetime in ms
    SessionKeepAliveIntervalMs = 0, // 0 = automatic idle heartbeat; negative = disabled
    ChannelLifetime = 3600000,    // secure channel lifetime in ms
    WarmupOnConnect = true,       // warm up the Read codec path after connecting
    MaxMessageSize = 0,           // 0 = use server default

    Security = new SecurityOptions
    {
        Policy = "Basic256Sha256",                  // None, Basic256Sha256, Aes128Sha256RsaOaep, Aes256Sha256RsaPss
        Mode = MessageSecurityMode.SignAndEncrypt,  // None, Sign, SignAndEncrypt
        AutoDiscoverServerCertificate = true,       // auto-discover via GetEndpoints
        // false validates dates, EKU and chain integrity. Unknown self-signed roots are still
        // allowed and CRL revocation is not checked, so deploy an external trust/pinning policy
        // when your environment requires strict server identity verification.
        AutoAcceptServerCertificate = false,
        UserIdentity = new UserIdentityOptions
        {
            Type = UserTokenType.UserName,
            Username = "user",
            Password = "pass"
        },
        Certificate = new CertificateOptions
        {
            AutoGenerate = true,
            KeySize = 2048,
            ValidityYears = 5
        }
    },

    ReconnectMaxRetries = -1,        // -1 = unlimited
    ReconnectInitialDelayMs = 1000,  // start with 1s delay
    ReconnectMaxDelayMs = 30000,     // cap at 30s (exponential backoff)

    MaxPublishRequests = 2,          // finite session-level Publish credit window
    SubscriptionDispatch = new SubscriptionDispatchOptions
    {
        QueueCapacity = 64,
        OverflowPolicy = NotificationOverflowPolicy.Wait
    },

    ErrorMode = ErrorMode.Throw      // Throw or ReturnNull
};

Live Verification and Diagnostics

The live-server commands default to opc.tcp://localhost:49320; pass another URL as the final argument when needed:

# Connection, Browse, batch Read/Write, writable DemoCH/Data tags, and backpressure correctness
dotnet run --project TinyUa.Benchmarks -- none opc.tcp://localhost:49320

# Fast and slow subscription callback stress
dotnet run --project TinyUa.Benchmarks -- substress opc.tcp://localhost:49320

# Continuous DemoCH/Data batch Read/Write with allocation, GC, ArrayPool, and encoder-pool telemetry
# Runs until Ctrl+C
dotnet run --project TinyUa.Benchmarks -- memory opc.tcp://localhost:49320

# General throughput benchmark
dotnet run --project TinyUa.Benchmarks -- basic opc.tcp://localhost:49320

# Cryptographic primitive benchmarks (no server required)
dotnet run --project TinyUa.Benchmarks -- security

Logging

Choose one logging configuration when building the client.

using TinyUa.Client;
using TinyUa.Core.Logging;

// Console logging via delegate
await using var client = await UaClient.ConnectTo("opc.tcp://server:4840")
    .EnableLog((level, ex, msg) => Console.WriteLine($"[{level}] {msg}"))
    .BuildAndRunAsync();
using TinyUa.Client;
using TinyUa.Core.Logging;

// Custom ILogger implementation
await using var client = await UaClient.ConnectTo("opc.tcp://server:4840")
    .WithLogger(new DelegateLogger((level, ex, msg) =>
        Console.WriteLine($"[{level}] {msg}"), LogLevel.Debug))
    .BuildAndRunAsync();
using TinyUa.Client;
using TinyUa.Core.Logging;

// File logging (directory path, auto-creates log files)
await using var client = await UaClient.ConnectTo("opc.tcp://server:4840")
    .EnableLogFile("./logs", LogLevel.Debug, async: true)
    .BuildAndRunAsync();

Documentation