Files
UA-.NETStandard/samples/ConsoleReferenceClient/ConnectTester.cs
Marc Schier aad8350bc1 Pluggable symmetric cryptography, an awaitable asymmetric path, registrable security policies, and PubSub crypto offboarding (#4206, #4207, #4208, #4210) (#4232)
# Description

Closes the three crypto-offboarding follow-ups split out of #4192. Each
was recorded there as knowingly *not* done, with the reason and what
would justify revisiting it. Those conditions are met, and where one of
them turned out to be unachievable that is written down rather than
worked around.

The safety property throughout: **every seam is opt-in and null-checked,
and in the default software configuration the added code is observably
inert.** No interface dispatch on the per-message path, and the
asynchronous paths complete synchronously — so channel locking order,
buffer ownership and error sequencing are unchanged for a deployment
that configures nothing.

## #4207 — symmetric, key-derivation and RNG provider seams

`ISymmetricCryptoProvider`, `IKeyDerivationProvider` and
`ISecureRandomSource`, added on the terms #4207 set: only once a
concrete requirement arrived (a validated module that must perform
*every* operation, not just the asymmetric ones), behind a fast path,
with `SymmetricChannelCryptoBenchmarks` as the gate.

They are **facets discovered by type test**, not members of
`ICryptoProvider`, so providers written against the shipped interface
still compile. `PlatformCryptoProvider` implements all three, which
answers the "an interface nothing implements" objection recorded in the
issue.

`CryptoProviderFacets` returns `null` both when no registry is
configured *and* when resolution lands on the platform, because the
platform facets are the inline code. Resolution happens once, in
`CalculateSymmetricKeySizes`, and is held for the life of the channel.

`CryptoCompliance.GetUnservedOperationPurposes` closes the failure the
seam would otherwise introduce: a provider bound to `ChannelSymmetric`
without implementing the facet is silently replaced by the platform, so
a deployment could believe its validated module performed the
per-message cryptography while the platform did. Under `FipsOnly` that
now refuses to start.

## #4208 — the asymmetric path is awaitable

`RSA` and `ECDsa` are .NET's contracts and cannot be replaced without
making every ready-made hardware and cloud implementation unusable.
Instead an implementation may **also** declare `IAsyncRsaKey` /
`IAsyncEcdsaKey`, which the stack finds by type test.

Now asynchronous: the secure channel open and renew path, user identity
token signing and decryption, and session activation. A software key
declares neither facet, so those paths complete synchronously and
nothing about their ordering changes.

`ReadAsymmetricMessage` is refactored into shared helpers rather than
duplicated — adding an `await` was not worth two hundred cloned lines of
signature, padding and endpoint-selection logic. `AsymmetricMessage` /
`AsymmetricWriteResult` exist because an asynchronous method cannot have
`out` parameters.

The channel no longer serialises its state on a monitor. A monitor
cannot be held across an `await`, and a plain `SemaphoreSlim` was not an
option at the time: the code relied on re-entrancy in eight places
(`HandleIncomingMessage` holds the lock and calls `ForceChannelFault`,
which takes it again; `ForceReconnect` and `Shutdown` do the same).
`ChannelGate` replaces it at 32 sites and `DataLock` is `[Obsolete]`.
That reliance has since been removed as well — see [Removing the gate's
re-entrancy](#removing-the-gates-re-entrancy) below.

### Defects this surfaced, all fixed and regression-tested

**Detached work started inline.** Writes were started with `_ =
WriteBuffersAsync(...)`. An async method runs its prologue on the
caller's stack, so `LeaveInheritedContext` — which exists to stop
*detached* work inheriting the right to re-enter the gate — was
stripping the **caller's own** entitlement; when the send then completed
synchronously, the completion blocked on a gate that very thread was
holding. The client sent its OpenSecureChannel request and never
processed the response. Writes are now queued.

**A synchronous gate handle held across an `await`.** `Enter()` records
the acquiring thread so an inline completion callback can re-enter —
`ChannelAsyncOperation` invokes its callback both inline and detached.
That record is only sound while the holder is synchronously on that
thread; the token renewal path held it across an await, so a reused pool
thread was recognised as the holder.

**`SignAsync` rejected the policy that signs nothing.** It validated the
certificate before the algorithm, so `SecurityPolicies.None` — which
carries no certificate — threw where the synchronous `Sign` returns
`null`.

**`EnterAsync` lost the holder when contended** *(found in review)*. The
`AsyncLocal` recording ownership was written inside the awaiting method,
and .NET restores the caller's execution context when an async method
completes, so the write was discarded. The caller returned owning the
semaphore while `IsHeldByCurrentContext` reported `false` — and any
nested `Enter()` then blocked forever on a gate its own context held.
Reachable remotely: `HandleIncomingMessageAsync` → error path →
`ForceChannelFault`. The holder is now published from the caller's frame
before the wait, and only its depth is raised in the continuation.

**Queued writes lost their order** *(found in review)*. Sequence numbers
are assigned under the gate, but independently queued work items reach
the transport's FIFO send lock in arbitrary order, so a peer could
reject a chunk with `BadSequenceNumberInvalid`. Writes are now appended
to a chain: still off the caller's stack, but a write cannot start
before its predecessor finishes.

### Four more this surfaced in CI, on paths only the integration suites
reach

Queuing the writes to fix the deadlock above turned out to have moved
three implicit guarantees, and the gate had one more liveness bug. Each
was reproduced locally against a clean baseline before it was changed.

**A faulting channel's error message never reached the peer.** A
listener channel sends a UASC `Error` describing why it faulted and
closes the transport as its next statement. That worked only because the
write started on the caller's stack, so the bytes reached the socket
first; queued, the close discarded them and the client saw
`BadConnectionClosed` instead of the certificate status it was told to
expect. Nine `SecurityCertValidationTests` cases assert exactly that
status. The terminal error is written inline again, on a path that
touches the gate only to disclaim what it inherited and reports nothing
— a channel that is already faulted is owed nothing beyond its buffer.

**The certificate of a rejected sender leaked.** An asynchronous method
cannot have `out` parameters, so the asymmetric read returns a record
instead. That silently dropped an ownership guarantee: an `out`
parameter reaches the caller even when the method goes on to throw, so
the caller's `catch` disposed the sender certificate and reported it to
the audit. A return value does not — nine leaked certificates, one per
rejection case, which the leak detector fails the run on. The parsed
certificate is now handed to the caller before anything that can reject
the message, restoring both the disposal and the audit.

**The gate could strand its own permit.** It released only when the
holder's depth returned to zero, but that depth lives in an `AsyncLocal`
and is therefore shared with every context forked from the owner's — a
timer callback whose timer was created inside the region, for instance.
Such a context entering the gate raised the shared depth, so the owner's
own exit saw a non-zero depth and returned without releasing, and
nothing afterwards could: the nested exit is not the owner. Every later
entry on that channel blocked forever. `Opc.Ua.Features.Tests` hung
after its tests had finished, with a receive loop waiting on the gate
and the server unable to stop. The owner now releases when it leaves;
depth still gates re-entrancy, but liveness no longer depends on a count
another context can move.

**Write completion throttled the writer.** Reporting a completed write
sat inside the ordering chain, and the client channel implements that
callback by entering the gate — so every write queued behind a gate the
receive loop contends for on each incoming chunk. A session with fifty
subscriptions publishing continuously had its publish requests throttled
enough that the client kept topping them up past its own limit, which
`Subscriptions.Classic` asserts against. Completion is now reported off
the chain, which covers only what ordering actually requires.

### Still synchronous, and why

Certificate, certificate request and revocation list signing cannot be
made asynchronous at all: `X509SignatureGenerator.SignData` is invoked
by .NET's own builders. Service faults and the `Reconnect` override are
reached from synchronous call sites (`SendServiceFault` alone has eight)
and are left as they are behind scoped, commented suppressions. All
three are documented rather than left to be rediscovered.

## #4210 — PubSub

This one produced a finding rather than the API the issue asked for, and
the finding is the point:

> **With a standard Security Key Service the key necessarily exists in
process memory.** `GetSecurityKeys` (Part 14 §8.3.2) returns raw key
bytes over the wire, so the "key never leaves the device" property #4192
achieved for client and server **cannot** be achieved for PubSub through
the SKS pull profile. That is a property of the specification, not of
this stack.

A wrapped-key envelope would change what is on the wire and break
interoperability with third-party key services and publishers, so it was
rejected rather than built.

What is achievable is delivered: the per-message AES-CTR and HMAC route
through `ISymmetricCryptoProvider`, so a validated module performs them.
`IPubSubSecurityPolicy` is **not** modified — new members would break
every implementation of it; the provider is supplied through the policy
constructor. `PlatformSymmetricCryptoProvider` gained AES counter mode,
which the seam should have covered anyway since the algorithm enum names
it.

Key lifetime is also tightened: unpacking an SKS response copied key
material into intermediates that `ByteString.Create` then copied again,
leaving plain keys in the heap for the lifetime of the process. Those
are now zeroed.

### Two wiring gaps this depended on, both found in review

The registrations above were **inert**, so this section originally
claimed more than the code did:

- `CryptoProviderConfiguration` was applied nowhere outside tests, so
`AddCryptoProvider(configure)` never reached the registry in a real
host. The registry now applies every registered configuration when it is
first resolved — which also affects #4207's DI story.
- `PubSubSecurityWrapperResolver` selected
`PubSubAes256CtrPolicy.Instance`, the provider-less static singleton,
ignoring the provider-backed bundles registered for it. A `FipsOnly`
deployment therefore certified while the platform did the work. The
resolver now selects from the registered policies; the container and
`PubSubApplicationBuilder` both supply theirs.

A third, smaller one: `CryptoProviderBuilder.Use` published a
**policy-scoped** random source as the process-wide nonce source, so
`.For(purpose, policyUri).Use(hsm)` redirected nonce generation for
every other policy too. Only an unscoped binding does that now.

## #4206 — a provider can contribute a security policy

Added in review: the policy set was fixed at compile time, so a provider
could contribute key custody but not a *profile*. It is now table-driven
and open, and the blockers #4206 listed are gone.

```csharp
using IDisposable registration = SecurityPolicies.Register(
    new SecurityPolicyInfo("urn:vendor:SecurityPolicy#CustomProfile", "CustomProfile")
    {
        PlatformSupport = () => true,
        SupportedCertificateTypes = [ObjectTypeIds.RsaSha256ApplicationCertificateType],
        IsDefault = true
    });

services.AddOpcUa()
    .AddCryptoProvider(crypto => crypto
        .For(CryptoPurpose.ChannelSymmetric, "urn:vendor:SecurityPolicy#CustomProfile").Use(module))
    .AddSecurityPolicy(customPolicy);
```

- **The reflection is gone.** Four lookup tables were built by
reflecting over the fields of `SecurityPolicies` and
`SecurityPolicyInfo`. They are one explicit table now, which removes the
**last reflection in `Security/Constants`** — so this is a trimming and
Native AOT improvement as much as a feature.
- **`SecurityPolicyInfo` is constructible from another assembly** and
carries the metadata the helpers need, so `IsPlatformSupportedName`,
`GetDefaultUris`, `MapSecurityPolicyToCertificateTypes` and
`GetCurveFromCertificateTypeId` read that table instead of each being
hand-written. Adding a policy is one entry rather than five edits.
- **The dead `#if CURVE25519` conditional is deleted** from
`Security/Constants`.
- Registration is copy-on-write, so the read path stays lock-free.
Shadowing a built-in policy requires `replaceExisting: true` and is
reversible by disposing the registration.

`RegisterLightsUpCurvePoliciesFromOutsideCore` is the acceptance test
the issue asked for: it registers `ECC_curve25519` and `ECC_curve448`
from the test project and asserts they become discoverable through the
public API.

**Registering a policy makes it advertised and resolvable; it does not
supply the cryptography behind it.** For those two curve profiles the
in-tree key agreement is still behind a compile symbol no project
defines plus a BouncyCastle dependency, so a deployment lighting them up
provides the operations through a provider. That division is the point:
the policy set says *what* is offered, the provider says *who* performs
it.

## Removing the gate's re-entrancy

Folded in from #4242, which was merged into this branch.

`ChannelGate` was re-entrant, and that forced the design behind most of
the defects above. A holder that awaits releases its thread, so
ownership could not be tracked by thread identity — it had to live in an
`AsyncLocal<Holder?>`. A logical context is *inherited*, so anything
started while the gate was held inherited the right to re-enter and ran
inside the guarded region alongside whatever started it, unless it opted
out by hand via `LeaveInheritedContext()` at each of eight sites,
enforced by review alone. Five of the defects listed above trace to that
mechanism, including both CI hangs.

So the *requirement* was removed rather than the mechanism, and the
`AsyncLocal` then deleted itself. Every path that re-entered now calls a
lock-free `Core` method (`ForceChannelFaultCore`, `ShutdownCore`,
`ForceReconnectCore`, `CompleteConnectCore`), and three regions stopped
running foreign code under the lock: the `RequestReceived` dispatch is
deferred until the gate is released, `OnTransportError` no longer wraps
the `HandleSocketError` virtual, and the callback setters and
`EndpointDescription` publish a single reference with a volatile write
instead of locking — the last is load-bearing, because
`AsyncResultBase.OperationCompleted` invokes the reverse-hello callback
inline while the gate is held.

`ChannelGate` is now a `SemaphoreSlim(1,1)` behind a `Releaser`;
`Holder`, the thread-identity fallback, `LeaveInheritedContext`,
`IsHeldByCurrentContext` and all eight disclaimer calls are gone, with
the call shape at all 32 sites unchanged. `SaveIntermediateChunk`,
`GetSavedChunks` and `DoMessageLimitsExceeded` gained a `gateHeld`
argument, because the client reaches them both with and without the gate
and must pick the matching teardown — without it an oversized
`OpenSecureChannel` response deadlocked the receive loop against itself.

The remaining paths were not found by reading the code, since that is
what missed them originally. A temporary strict mode made every
re-entrant acquisition throw with its stack, the suite was run under it
until clean, and it was deleted with the re-entrancy it measured. It
caught two sites review had missed: the `EndpointDescription` **property
getter** taking the gate, and the `RequestReceived` dispatch to
application code happening inside the guarded region.

Two deliberate residuals are documented for reviewers: `Dispose` no
longer takes the gate (unavoidable — it is reached from `ChannelFaulted`
with the gate held, where the old re-entrant acquisition excluded
nothing anyway), and the deferred request dispatch builds
`SecureChannelContext` just after gate release rather than under it.

## Testing

New: the three seams and their platform implementations, facet discovery
and resolution precedence, `FipsOnly` refusing a provider that cannot
serve what it was bound to, AES counter mode (including a
non-block-aligned round trip and a check that the counter carries, since
one that did not would silently reuse key stream), `ChannelGate`
(exclusion, re-entrancy across an await, re-entrancy on a reused thread,
and the deadlocks above), the asynchronous channel paths, and the PubSub
policy provider.

Each review finding got a test that fails without its fix:

- `ContendedEntryStillRecordsTheHolderAsync` — established contention
deterministically, then re-entered; failed on the pre-fix gate. Removed
with the re-entrancy it covered; `ChannelGateTests` now asserts the
non-re-entrant contract instead.
- `SecureClientAndTcpListenerKeepChunkOrderUnderConcurrentRequestsAsync`
— 60 concurrent multi-chunk `SignAndEncrypt` requests over a real
loopback listener; failed 3 runs out of 3 with the previous dispatch,
passes 3 out of 3 with the chain.
-
`DependencyInjectionRoutesPubSubCryptoThroughTheRegisteredProviderAsync`
— builds a container, registers a counting provider, wraps a message and
asserts the provider actually performed the encryption and the
signature. This is the assertion the PubSub claim above now rests on.
- `PolicyScopedRandomBindingDoesNotEscapeItsPolicy`.

Two further assertions are the ones that would catch a silent defect: a
provider and the platform must produce **byte-identical** output — a
validated module that disagreed could not interoperate — and with a
software key the returned `ValueTask` must report
`IsCompletedSuccessfully`, which is what makes "ordering is unchanged"
checkable rather than merely asserted.

`SymmetricChannelCryptoBenchmarks` gains a provider-registered variant
beside the existing baseline, so the cost of the indirection is measured
rather than assumed.

Verified locally, every suite at or above its recorded baseline: `dotnet
build UA.slnx -c Release` **0 warnings, 0 errors on all six target
frameworks**; Core **4413** (net10.0); Core.Security **617**, with its
certificate-leak detector clean; PubSub **1350** plus PubSub.Diagnostics
**54** and PubSub.Udp **233**; Features **156**; Subscriptions.Classic
**32**; Server integration **4053**; Client integration **2123**;
MigrationAnalyzer **151** and MigrationAnalyzer.Core **13**. Zero
failures in each.

Core moved from 4388 to 4381 when the gate change landed — eight
re-entrancy tests were removed along with the behaviour they covered,
and one deadlock regression test added — and has since grown to 4413
with the registry isolation, provider-output and security-review tests.
`ChannelGateTests` was rewritten around the new contract — exclusion
across both entry modes, an uncontended asynchronous entry not
suspending, re-entry blocking by contract, and the property that used to
be the hazard: work started while the gate is held is excluded without
having to opt out.

Every CI failure this PR produced was reproduced locally, diagnosed
against a clean `origin/master` baseline in a second worktree, and fixed
at the source. Two hangs were found from `--blame-hang-timeout` dumps
read with `dotnet-dump`. One failure that looked like a candidate was
confirmed **not** to be: an intermittent `Opc.Ua.Aot.Tests` connect
failure, pre-existing on `origin/master` (5 of 8 baseline runs failed,
versus 3 of 14 here).

### The publish request overshoot

`Subscriptions.Classic.PublishRequestCountAsync` failed on the Windows
net10.0 leg with **207** outstanding publish requests against a cap of
`max(maxServerPublishRequest, subscriptions)` = 50. It failed in 2 of 6
pipeline builds on this branch, never on `master` in 13 sampled builds,
and never locally in 12 attempts including under deliberate CPU
contention.

The mechanism is a genuine race.
`ClassicSubscriptionEngine.QueueBeginPublish` was a **check-then-act**:
it read `GoodPublishRequestCount`, compared it against
`GetDesiredPublishRequestCount(false)`, and issued a publish if it was
below. It runs on every publish completion, so *n* completions that
overlap each read the same value, each conclude they are below the cap,
and each issue — overshooting by up to *n* per round. The read also
*lags*: a request only becomes visible to `GoodPublishRequestCount` once
`AsyncRequestStarted` has recorded it, which happens after the request
has already been sent.

That code is pre-existing and was untouched by this PR. What this PR
changed is how hard the race is hit: the server used to hold the channel
gate across request processing, which serialised every request on a
channel, so publish completions arrived one at a time. Dispatching
`RequestReceived` outside the gate — an intended improvement, since a
channel should not serialise on application request-processing time —
lets them complete concurrently.

Rather than change the publish pipeline blind, the race was first made
reproducible.
`ConcurrentPublishReEvaluationDoesNotExceedDesiredRequestCount` drives
64 barrier-released completions against a mocked context whose count
lags the send exactly the way the session's does. It issued **64
requests where 5 were desired** — the same defect the CI leg hit, now
deterministic and running in milliseconds.

The engine now tracks the requests it has in flight and reserves a slot
with a compare-exchange before sending, so the decision and the send are
one atomic step. The reservation is released when the request completes,
when the send is declined, or when it throws. The repro test then issues
at most the desired count.

`StartPublishing` is deliberately left **uncapped**. It is the recovery
valve that refills a pipeline whose requests are outstanding but no
longer expected to return, so gating it on the in-flight count would
remove the only path out of a stalled pipeline. An earlier revision of
this fix did gate it, and that is the one change here that could not be
justified on the evidence — it was reverted.

The assertion was **not** loosened, retried away, or marked flaky: it
caught a real throttling regression earlier in this PR and is worth
keeping sharp.

Validated: the repro test red before the fix and green after;
`Subscriptions.Classic` 32/32 **five** consecutive runs on net10.0 and
once on net48; `Opc.Ua.Client.Tests` 2124 on net10.0 across four
consecutive runs and 2127 on net48; `Opc.Ua.Subscriptions.Tests` 604
passed with this fix in place.

### The SSH.NET advisory that turned CI red repo-wide

Partway through this work every build job began failing with `NU1903:
Package 'SSH.NET' 2025.1.0 has a known high severity vulnerability`
([GHSA-q939-rpr3-3284](https://github.com/advisories/GHSA-q939-rpr3-3284),
`<= 2025.1.0` affected, first patched `2026.0.0`).

Nothing in the dependency graph changed — the advisory was published.
Build 17026 failed on it where build 17000, two hours earlier on an
identical graph, had passed every build job. SSH.NET is not referenced
anywhere in the repository; it arrives transitively through
`Testcontainers.Kafka`.

`master` landed the remediation independently while this was being
investigated (a central transitive pin to `2026.0.0`, which
`CentralPackageTransitivePinningEnabled` exists for). This branch
briefly carried its own identical pin, which made the merge commit hold
**two** `PackageVersion` entries for SSH.NET and fail restore with
`NU1506`. The duplicate is removed and `master` is merged, so the pin
now comes from one place only.

The audit was **not** suppressed: `NuGetAudit` stays enabled and
`NU1903` stays an error. Verified after the merge — `dotnet restore
UA.slnx` reports neither NU1903 nor NU1506, and `dotnet build UA.slnx -c
Release` is **0 warnings, 0 errors** with the audit live.

The remaining red check in build 17026 was not from this change either.
`aot-ubuntu-latest` is the intermittent `Opc.Ua.Aot.Tests` failure
already noted above — `ReconnectSessionAsync` timed out at 30s with 135
of 136 passing. It remains below the `origin/master` baseline rate (4 of
15 sampled here against 5 of 8 on the baseline), and the managed
reconnect paths are green throughout: `Opc.Ua.Client.Tests` 2124 across
four consecutive runs and `Opc.Ua.Subscriptions.Tests` 604.


## Documentation

`docs/CryptoProvider.md` gains sections on substituting the symmetric
primitives, using a key served over a network, and PubSub — including
what cannot be claimed and why, the scope rule for a random-source
binding, and how to supply a provider-backed policy outside the
container. `docs/MigrationGuide.md` covers the `[Obsolete]` channel
virtuals and `DataLock`, and the non-re-entrant gate's contract for
channel subclasses. `docs/WhatsNewIn2.0.md` is updated.
`plans/cryptooffboard.md` is deleted now that the work it planned has
landed.

## Note for reviewers

This touches the secure channel handshake, which is the most
security-critical code in the stack. Nine distinct concurrency and
lifetime defects were found and fixed in it during this work — three
during development, two in review, four from CI — so `ChannelGate`, the
write path and the certificate ownership across the new suspension
points deserve the closest reading.

Five of those nine traced to the gate's `AsyncLocal`, which is why the
re-entrancy was subsequently removed and the `AsyncLocal` deleted. The
wrapper/`Core` split and the two documented residuals in that section
are the parts most worth a second reading.

The #4206 work removes the last reflection from `Security/Constants` and
rewrites the policy lookup tables, so
`SecurityConfiguration.SupportedSecurityPolicies` and the endpoint set
it feeds are worth a second look: an earlier revision of it changed that
set and broke six server tests, which is now covered by
`SecurityConfigurationBuildsExpectedSupportedPolicySet`.

## Related Issues

- Fixes #4206
- Fixes #4207
- Fixes #4208
- Fixes #4210
- Follow-up #4250 — this PR makes the security policy set injectable,
but nothing in the stack resolves `ISecurityPolicyRegistry` from DI yet,
so every production path still lands on the `Default` fallback. #4250
tracks threading it through `Session` and `UaSCBinaryChannel`, which is
what makes the injection points already added here load-bearing.

## Checklist

- [x] 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.
- [x] 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.
- [x] 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>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Copilot-Session: 3f5ab76a-c0ba-4f47-87ac-54d1f4182c6b
2026-08-15 05:23:20 -07:00

615 lines
25 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 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.Collections.Generic;
using System.IO;
#if NET8_0_OR_GREATER
using System.Linq;
#endif
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using Opc.Ua.Identity;
using Opc.Ua.Security.Certificates;
using Quickstarts.ConsoleReferenceClient;
namespace Quickstarts
{
/// <summary>
/// Wraps connect testing functionality
/// </summary>
public sealed class ConnectTester : IAsyncDisposable
{
public ConnectTester(
ITelemetryContext telemetry,
ManualResetEvent? quitEvent = null)
{
m_quitEvent = quitEvent;
m_telemetry = telemetry;
m_logger = telemetry.CreateLogger<ConnectTester>();
}
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
m_reconnectHandler?.Dispose();
ISession? session = m_wrapper?.Session;
if (session != null)
{
await session.DisposeAsync().ConfigureAwait(false);
}
GC.SuppressFinalize(this);
}
/// <summary>
/// Run the tests until cancelled or quit
/// </summary>
/// <param name="ct"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task RunAsync(CancellationToken ct)
{
try
{
m_reconnectHandler = new SessionReconnectHandler(
m_telemetry,
true,
kReconnectPeriodExponentialBackoff);
m_logger.OpcUaSecurityTestClient();
// The application name and config file names
const string applicationName = "ConsoleReferenceClient";
const string configSectionName = "Quickstarts.ReferenceClient";
// Define the UA Client application
var passwordProvider = new CertificatePasswordProvider([]);
var application = new ApplicationInstance(m_telemetry)
{
ApplicationName = applicationName,
ApplicationType = ApplicationType.Client,
ConfigSectionName = configSectionName,
CertificatePasswordProvider = passwordProvider
};
await using (application.ConfigureAwait(false))
{
// load the application configuration.
m_configuration = await application
.LoadApplicationConfigurationAsync(silent: false, ct: ct)
.ConfigureAwait(false);
m_configuration.CertificateManager.AcceptError = AcceptCertificate;
// check the application certificate.
bool haveAppCertificate = await application
.CheckApplicationInstanceCertificatesAsync(false, ct: ct)
.ConfigureAwait(false);
if (!haveAppCertificate)
{
throw new InvalidOperationException("Application instance certificate invalid!");
}
}
m_logger.ConnectingTo(kServerUrl);
ArrayOf<EndpointDescription> endpoints = await GetEndpointsAsync(
m_configuration,
kServerUrl,
ct).ConfigureAwait(false);
var endpointConfiguration = EndpointConfiguration.Create(m_configuration);
var sessionFactory = new DefaultSessionFactory(m_telemetry);
IClientIdentityProvider userNameProvider = await CreateUserNameProviderAsync(ct)
.ConfigureAwait(false);
foreach (EndpointDescription ii in endpoints.ToArray()!)
{
string securityPolicyUri = ii.SecurityPolicyUri!;
string userCertificateFile = GetUserCertificateFile(securityPolicyUri);
X509Certificate2 x509 = X509CertificateLoader.LoadPkcs12FromFile(
Path.Combine("..\\..\\pki\\trustedUser\\private",
userCertificateFile),
"password");
string thumbprint = x509.Thumbprint!;
IClientIdentityProvider certificateProvider = await LoadUserCertificateProviderAsync(
thumbprint,
"password",
ct).ConfigureAwait(false);
var identityProviders = new List<IClientIdentityProvider>
{
new AnonymousIdentityProvider()
};
if (!string.IsNullOrEmpty(kUserName))
{
identityProviders.Add(userNameProvider);
}
if (kSupportsX509)
{
identityProviders.Add(certificateProvider);
}
foreach (IClientIdentityProvider identityProvider in identityProviders)
{
IUserIdentity identity = await CreateUserIdentityAsync(
identityProvider,
ii,
ct).ConfigureAwait(false);
try
{
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SeparatorLine(new string('=', 80));
}
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SecurityPolicy(
SecurityPolicies.Default.GetDisplayName(securityPolicyUri),
ii.SecurityMode);
}
m_logger.Identity(
identity.DisplayName,
identity.TokenType);
SessionWrapper wrapper = m_wrapper = await RunTestAsync(
endpointConfiguration,
sessionFactory,
ii,
identity,
ct).ConfigureAwait(false);
m_logger.WaitingForSecureChannelRenew();
await wrapper.Session.UpdateSessionAsync(identity, default, ct).ConfigureAwait(false);
for (int count = 0; count < 1; count++)
{
ReadResponse result = await wrapper.Session.ReadAsync(
null,
0,
TimestampsToReturn.Neither,
new List<ReadValueId>
{
new() {
NodeId = VariableIds.Server_ServerStatus_CurrentTime,
AttributeId = Attributes.Value
}
},
ct).ConfigureAwait(false);
m_logger.CurrentTime(result.Results[0].WrappedValue.GetDateTime());
await Task.Delay(5000, ct).ConfigureAwait(false);
}
await wrapper.Session.UpdateSessionAsync(identity, default, ct).ConfigureAwait(false);
await wrapper.Session.CloseAsync(true, ct: ct).ConfigureAwait(false);
}
catch (Exception e)
{
Console.WriteLine("Exception: {0}", e.Message);
Console.WriteLine("StackTrace: {0}", e.StackTrace);
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SecurityPolicy(
SecurityPolicies.Default.GetDisplayName(securityPolicyUri),
ii.SecurityMode);
}
m_logger.Identity(
identity.DisplayName,
identity.TokenType);
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SeparatorLine(new string('=', 80));
}
}
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.TestComplete(
SecurityPolicies.Default.GetDisplayName(securityPolicyUri),
ii.SecurityMode);
}
if (m_logger.IsEnabled(LogLevel.Warning))
{
m_logger.SeparatorLine(new string('=', 80));
}
}
}
Console.WriteLine("Ctrl-C to stop.");
m_quitEvent!.WaitOne();
}
catch (Exception e)
{
m_logger.ExceptionMessage(e.Message);
m_logger.StackTrace(e.StackTrace);
}
}
internal async Task<SessionWrapper> RunTestAsync(
EndpointConfiguration endpointConfiguration,
DefaultSessionFactory sessionFactory,
EndpointDescription endpointDescription,
IUserIdentity identity,
CancellationToken ct)
{
var endpoint = new ConfiguredEndpoint(
null,
endpointDescription,
endpointConfiguration);
// Create the session
ISession isession = await sessionFactory
.CreateAsync(
m_configuration,
endpoint,
false,
false,
m_configuration.ApplicationName!,
600000,
endpointDescription.SecurityMode != MessageSecurityMode.None
? identity
: await CreateUserIdentityAsync(
new AnonymousIdentityProvider(),
endpointDescription,
ct).ConfigureAwait(false),
default,
ct
)
.ConfigureAwait(false);
bool ownsSession = true;
try
{
SessionWrapper wrapper = m_wrapper = new SessionWrapper { Session = isession };
ownsSession = false;
// Assign the created session
if (!wrapper.Session.Connected)
{
throw new InvalidOperationException("Could not connect to server at " + kServerUrl);
}
wrapper.Session.KeepAliveInterval = 10000;
wrapper.Session.KeepAlive += Session_KeepAlive;
var samples = new ClientSamples(m_telemetry, null, m_quitEvent);
ArrayOf<ReferenceDescription> nodes = await samples.BrowseFullAddressSpaceAsync(
wrapper,
ObjectIds.ObjectsFolder,
null,
ct).ConfigureAwait(false);
return wrapper;
}
finally
{
if (ownsSession)
{
await isession.DisposeAsync().ConfigureAwait(false);
}
}
}
private static async ValueTask<UserNamePasswordIdentityProvider> CreateUserNameProviderAsync(
CancellationToken ct)
{
var passwordStore = new InMemorySecretStore();
var passwordId = new SecretIdentifier(
"connect-tester-password",
passwordStore.StoreType);
await passwordStore
.SetAsync(passwordId, new UTF8Encoding(false).GetBytes(kPassword), ct)
.ConfigureAwait(false);
return new UserNamePasswordIdentityProvider(
kUserName,
new SecretRegistry(passwordStore),
passwordId);
}
private ValueTask<IUserIdentity> CreateUserIdentityAsync(
IClientIdentityProvider provider,
EndpointDescription endpointDescription,
CancellationToken ct)
{
return provider.AcquireIdentityAsync(
endpointDescription,
m_configuration.CreateMessageContext(),
ct);
}
private async Task<IClientIdentityProvider> LoadUserCertificateProviderAsync(
string thumbprint,
string password,
CancellationToken ct)
{
CertificateTrustList store = m_configuration.SecurityConfiguration.TrustedUserCertificates!;
#if NET8_0_OR_GREATER
// get user certificate with matching thumbprint
using CertificateCollection certificates =
await store.GetCertificatesAsync(m_telemetry, ct).ConfigureAwait(false);
using Certificate hit = certificates
.Find(X509FindType.FindByThumbprint, thumbprint, false)
.FirstOrDefault()!;
// create Certificate Identifier
var cid = new CertificateIdentifier
{
Thumbprint = hit.Thumbprint,
SubjectName = hit.Subject,
StorePath = store.StorePath,
StoreType = store.StoreType
};
return new X509ClientIdentityProvider(
cid,
new CertificatePasswordProvider(new UTF8Encoding(false).GetBytes(password)),
m_configuration.CertificateManager.CertificateProvider);
#else
throw new NotSupportedException("User certificate identity requires net8.0 or greater.");
#endif
}
private static async ValueTask<ArrayOf<EndpointDescription>> GetEndpointsAsync(
ApplicationConfiguration application,
string discoveryUrl,
CancellationToken ct = default)
{
var endpointConfiguration = EndpointConfiguration.Create(application);
using DiscoveryClient client = await DiscoveryClient.CreateAsync(
application,
new Uri(discoveryUrl),
endpointConfiguration,
ct: ct).ConfigureAwait(false);
return await client.GetEndpointsAsync(default, ct).ConfigureAwait(false);
}
private bool AcceptCertificate(Certificate certificate, ServiceResult error)
{
// ****
// Implement a custom logic to decide if the certificate should be
// accepted. Return true to accept, false to reject.
// ***
m_logger.ServiceResult(error);
bool certificateAccepted = error.StatusCode == StatusCodes.BadCertificateUntrusted;
if (certificateAccepted)
{
m_logger.UntrustedCertificateAccepted(certificate.Subject);
}
else
{
m_logger.UntrustedCertificateRejected(certificate.Subject);
}
return certificateAccepted;
}
/// <summary>
/// Handles a keep alive event from a session and triggers a reconnect if necessary.
/// </summary>
private void Session_KeepAlive(ISession session, KeepAliveEventArgs e)
{
try
{
// check for events from discarded sessions.
if (m_wrapper == null || !m_wrapper.Session.Equals(session))
{
return;
}
// start reconnect sequence on communication error.
if (ServiceResult.IsBad(e.Status))
{
SessionReconnectHandler.ReconnectState state = m_reconnectHandler!
.BeginReconnect(
m_wrapper.Session,
null,
kReconnectPeriod,
Client_ReconnectComplete
);
if (state == SessionReconnectHandler.ReconnectState.Triggered)
{
m_logger.KeepAliveReconnectStatusWithPeriod(
e.Status,
state,
kReconnectPeriod);
}
else
{
m_logger.KeepAliveReconnectStatus(
e.Status,
state);
}
// cancel sending a new keep alive request, because reconnect is triggered.
e.CancelKeepAlive = true;
}
}
catch (Exception exception)
{
m_logger.ErrorInOnKeepAlive(exception);
}
}
private void Client_ReconnectComplete(object? sender, EventArgs e)
{
// ignore callbacks from discarded objects.
if (!ReferenceEquals(sender, m_reconnectHandler))
{
return;
}
lock (m_lock)
{
// if session recovered, Session property is null
if (m_reconnectHandler!.Session != null)
{
// ensure only a new instance is disposed
// after reactivate, the same session instance may be returned
if (!ReferenceEquals(m_wrapper!.Session, m_reconnectHandler.Session))
{
m_logger.ReconnectedToNewSession(m_reconnectHandler.Session.SessionId);
ISession session = m_wrapper.Session;
m_wrapper = new SessionWrapper { Session = m_reconnectHandler.Session };
session?.Dispose();
}
else
{
m_logger.ReactivatedSession(m_reconnectHandler.Session.SessionId);
}
}
else
{
m_logger.ReconnectKeepAliveRecovered();
}
}
}
private static string GetUserCertificateFile(string securityPolicyUri)
{
// GetInfo returns null only for null/empty URI; caller passes a non-empty value.
SecurityPolicyInfo securityPolicy = SecurityPolicies.Default.GetInfo(securityPolicyUri)!;
switch (securityPolicy.CertificateKeyAlgorithm)
{
case CertificateKeyAlgorithm.BrainpoolP256r1:
return "iama.tester.brainpoolP256r1.pfx";
case CertificateKeyAlgorithm.BrainpoolP384r1:
return "iama.tester.brainpoolP384r1.pfx";
case CertificateKeyAlgorithm.NistP256:
return "iama.tester.nistP256.pfx";
case CertificateKeyAlgorithm.NistP384:
return "iama.tester.nistP384.pfx";
default:
return "iama.tester.rsa.pfx";
}
}
internal sealed class SessionWrapper : IUAClient
{
public required ISession Session { get; init; }
}
private readonly Lock m_lock = new();
private SessionReconnectHandler? m_reconnectHandler;
private ApplicationConfiguration m_configuration = null!;
private SessionWrapper? m_wrapper;
private readonly ILogger m_logger;
private readonly ITelemetryContext m_telemetry;
private readonly ManualResetEvent? m_quitEvent;
private const string kServerUrl = "opc.tcp://localhost:62541";
private const string kUserName = "sysadmin";
private const string kPassword = "demo";
private const bool kSupportsX509 = true;
private const int kReconnectPeriod = 1000;
private const int kReconnectPeriodExponentialBackoff = 15000;
}
internal static partial class ConnectTesterLog
{
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 0, Level = LogLevel.Information,
Message = "OPC UA Security Test Client")]
public static partial void OpcUaSecurityTestClient(this ILogger logger);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 1, Level = LogLevel.Information,
Message = "Connecting to... {ServerUrl}")]
public static partial void ConnectingTo(this ILogger logger, string serverUrl);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 2, Level = LogLevel.Warning,
Message = "{Line}")]
public static partial void SeparatorLine(this ILogger logger, string line);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 3, Level = LogLevel.Warning,
Message = "SECURITY-POLICY={SecurityPolicyUri} {SecurityMode}")]
public static partial void SecurityPolicy(
this ILogger logger,
string? securityPolicyUri,
MessageSecurityMode securityMode);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 4, Level = LogLevel.Warning,
Message = "IDENTITY={DisplayName} {TokenType}")]
public static partial void Identity(
this ILogger logger,
string displayName,
UserTokenType tokenType);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 5, Level = LogLevel.Warning,
Message = "Waiting for SecureChannel renew")]
public static partial void WaitingForSecureChannelRenew(this ILogger logger);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 6, Level = LogLevel.Warning,
Message = "CurrentTime: {CurrentTime}")]
public static partial void CurrentTime(this ILogger logger, DateTimeUtc currentTime);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 7, Level = LogLevel.Warning,
Message = "TEST COMPLETE: {SecurityPolicyUri} {SecurityMode}")]
public static partial void TestComplete(
this ILogger logger,
string? securityPolicyUri,
MessageSecurityMode securityMode);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 8, Level = LogLevel.Error,
Message = "Exception: {Message}")]
public static partial void ExceptionMessage(this ILogger logger, string message);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 9, Level = LogLevel.Trace,
Message = "StackTrace: {StackTrace}")]
public static partial void StackTrace(this ILogger logger, string? stackTrace);
[LoggerMessage(EventId = ConsoleReferenceClientEventIds.ConnectTester + 10, Level = LogLevel.Information,
Message = "{ServiceResult}")]
public static partial void ServiceResult(this ILogger logger, ServiceResult serviceResult);
}
}