Skip to content

Pluggable symmetric cryptography, an awaitable asymmetric path, registrable security policies, and PubSub crypto offboarding (#4206, #4207, #4208, #4210) - #4232

Open
marcschier wants to merge 32 commits into
masterfrom
marcschier/crypto-offboard-followups
Open

Pluggable symmetric cryptography, an awaitable asymmetric path, registrable security policies, and PubSub crypto offboarding (#4206, #4207, #4208, #4210)#4232
marcschier wants to merge 32 commits into
masterfrom
marcschier/crypto-offboard-followups

Conversation

@marcschier

@marcschier marcschier commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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 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 Symmetric, key-derivation and RNG have no provider seam, so a validated module cannot perform every operation #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.

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, <= 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

Checklist

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

marcschier and others added 5 commits August 9, 2026 19:44
`ISymmetricCryptoProvider`, `IKeyDerivationProvider` and `IRandomSource` were
considered while adding the crypto provider model in #4192 and deliberately left
out. The only consumer that would have justified them was hardware offload, and
a device round trip per message would destroy throughput rather than help it.

The consumer that does justify them is a validated cryptographic module that
must perform *every* operation rather than only the asymmetric ones. That is a
real FIPS requirement, and #4207 recorded the terms on which the seam should be
added if it arrived. Those terms are met here.

## Declared as facets, not as members

The three interfaces are separate from `ICryptoProvider` and are discovered by
type test. A provider written against the shipped interface still compiles, and
a provider that can serve only some of them says so.

This is also where the model differs from the asymmetric one, deliberately.
`ICryptoProvider` declares capability rather than operations because `RSA` and
`ECDsa` were already the right abstraction. The platform offers nothing that
covers the block cipher, the authenticated cipher and the message authentication
code together, so these interfaces have to declare operations.

`CryptoPurpose.KeyDerivation` joins the existing `ChannelSymmetric` and
`RandomNumberGeneration`.

## The seam ships with an implementation

`PlatformSymmetricCryptoProvider`, `PlatformKeyDerivationProvider` and
`PlatformRandomSource` perform exactly what the channel would otherwise perform
inline, and `PlatformCryptoProvider` carries all three. Adding the seam with
nothing implementing it was the objection recorded in #4207; it does not apply.

## Nothing is paid for by a deployment that does not use it

`CryptoProviderFacets` returns `null` both when no registry is configured and
when resolution lands on the platform provider, because the platform facets are
the inline code. A `null` facet tells the caller to take the path it already
took, so the per-message path has no interface dispatch at all unless a provider
was registered.

Resolution happens once, in `CalculateSymmetricKeySizes`, and is held for the
life of the channel. It is never consulted per message.

## A provider that cannot do what it was bound to

Binding a provider to `ChannelSymmetric` without implementing
`ISymmetricCryptoProvider` would otherwise be silent: resolution falls through
to the platform and the channel keeps working, while a deployment believes its
validated module performed the per-message cryptography.

`CryptoCompliance.GetUnservedOperationPurposes` reports exactly that, and under
`FipsOnly` the auditor now refuses to start rather than run on cryptography the
operator did not ask for.

## Tests

`SymmetricCryptoProviderTests` covers facet discovery, resolution precedence,
the null fast path, and that a registered provider is actually called. Two of
them are the ones that would catch a silent defect: a provider and the platform
must produce identical bytes, so a validated module can interoperate, and the
platform facets must agree with `Utils.PSHA256` and `Nonce.DeriveHkdfKeyData`.
`CryptoProviderAotTests` covers the same discovery under trimming, since it must
stay free of reflection.

`SymmetricChannelCryptoBenchmarks` gains
`EncryptSignThenDecryptVerifyThroughProvider` beside the existing baseline, so
the cost of the indirection is measured rather than assumed.

Verified: `dotnet build UA.slnx -c Release` clean on all six target frameworks;
Core suite green on net10.0 and net48.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
…4208)

`RSA` and `ECDsa` are synchronous contracts, and they are .NET's rather than
this stack's, so a key served over a network — a cloud key service, a remote
signing service — occupies a thread for the whole of every call. They cannot be
replaced: hardware and cloud implementations of them already exist, and
competing with them would make those unusable.

The way out is for an implementation to *also* declare an asynchronous path.
`IAsyncRsaKey` and `IAsyncEcdsaKey` are opt-in facets found by type test, so a
key that does not implement them is unaffected.

## The property that makes this safe

Every asynchronous path added here **completes synchronously** unless the key
actually implements a facet. A software key therefore behaves exactly as it did,
including the order in which everything around the call happens. The secure
channel handshake is not a place to introduce suspension points for deployments
that gain nothing from them.

## What is now asynchronous

The call sites that were already asynchronous but performed synchronous
cryptography inside: `X509IdentityTokenHandler.SignAsync`,
`UserNameIdentityTokenHandler.DecryptAsync` and
`IssuedIdentityTokenHandler.DecryptAsync`. Session activation against a remote
key service no longer occupies a thread, with no API change.

`CryptoUtils.SignAsync`, `RsaUtils.DecryptAsync`,
`SecurityPolicies.CreateSignatureDataAsync` and `SecurityPolicies.DecryptAsync`
carry the facets.

## The channel no longer serialises its state on a monitor

This is the prerequisite for extending the above to the handshake: a monitor
cannot be held across an `await`. `ChannelGate` replaces it at 32 call sites,
and `UaSCBinaryChannel.DataLock` is `[Obsolete]` and guards nothing.

A plain `SemaphoreSlim` would have deadlocked immediately. The monitor is
re-entrant and the channel relies on that in eight places —
`HandleIncomingMessage` holds it and calls `ForceChannelFault`, which takes it
again, and `ForceReconnect` and `Shutdown` do the same.

Two properties were found the hard way, and both are covered by tests rather
than argued:

**Re-entrancy has to follow thread identity as well as logical context.**
`ChannelAsyncOperation` invokes its completion callback *inline* as well as
detached. Inline, it runs on the thread that already holds the gate but under
the context captured when the operation started, so a context-only check does
not recognise it and the thread deadlocks against itself. This crashed the
entire channel test suite before it was fixed, and a method invoked both inline
and detached must therefore not disclaim its inherited context.

**Work started while the gate is held inherits the right to re-enter**, and
would then run inside the guarded region alongside its parent. The receive loop,
both write paths and the reverse-connect tasks disclaim it with
`LeaveInheritedContext`; `ChannelAsyncOperation` queues its detached callback
without flowing the context at all.

The gate is deliberately not disposable. It replaces a monitor, which has no
disposed state, and channel teardown enters it and then runs paths that enter it
again; disposing turned twelve tests into `ObjectDisposedException`.

## Still open

The UASC open path itself — `HandleIncomingMessage`,
`ProcessOpenSecureChannelRequest`/`Response` and `Read`/`WriteAsymmetricMessage`
are still synchronous, so opening and renewing a channel against a remote key
still occupies a thread. #4208 stays open for it; the gate is what it needs.

Certificate issuance cannot be fixed at all: `X509SignatureGenerator.SignData`
is called by .NET's own `CertificateRequest` and CRL builders. That is recorded
in the documentation rather than left to be rediscovered.

## Tests

`ChannelGateTests` proves exclusion, re-entrancy across an `await`, re-entrancy
on the same thread under a different context, that inherited entitlement is real
and that disclaiming drops it, and that eight contending contexts never overlap.

Verified: `dotnet build UA.slnx -c Release` clean on all six target frameworks;
Core 4264 passed on net10.0 and 4189 on net48; Server integration 4048 passed;
Client integration 2123 passed. Compared against a stashed baseline to confirm
the channel suites match master plus the new tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Two defects in code that shipped in 226fba6, found while attempting the
asynchronous open path. Both are fixed here on their own, because both are real
independently of that work.

## A synchronous gate handle must not be held across an await

`ChannelGate.Enter()` records the acquiring thread so that a completion callback
invoked *inline* can re-enter — `ChannelAsyncOperation` invokes its callback both
inline and detached, and without this the inline path deadlocks against itself.

That record is only meaningful while the holder is synchronously on that thread.
The moment such a holder suspends, the thread goes back to the pool, and
unrelated work scheduled onto it is recognised as the holder: it enters the
guarded region alongside the real holder and, on leaving, decrements the shared
depth. When the depth reaches zero the semaphore is released while the holder is
still inside, and mutual exclusion is gone.

`EnterAsync` therefore no longer records thread identity at all — an asynchronous
holder releases its thread at every await, so thread identity can never be a
sound signal for it. `Enter()` keeps the record, and its contract now says
plainly that the handle must not be held across an await.

`ChannelGateTests.WorkReusingASuspendedHoldersThreadDoesNotInheritTheGateAsync`
covers it, so the failure mode is detectable rather than silent. Nothing in the
stack holds a synchronous handle across an await today; this closes the hazard
before something does.

## SignAsync rejected the security policy that signs nothing

`CryptoUtils.SignAsync` validated the certificate before looking at the
algorithm, so `SecurityPolicies.None` — which signs nothing and carries no
certificate — threw `ArgumentNullException` where the synchronous `Sign` returns
null. The order is now the other way round, matching the synchronous path.

Verified: `dotnet build UA.slnx -c Release` clean on all six target frameworks;
Core 4265 passed on net10.0 (the baseline plus the new test); Server integration
4048 of 4053, matching the recorded baseline exactly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Completes #4208. A private key served over a network — a cloud key service, a
remote signing service — no longer occupies a thread while a secure channel is
opened or renewed.

`IAsyncRsaKey` / `IAsyncEcdsaKey` and the async primitives shipped earlier; what
was missing was the path through the channel itself. `HandleIncomingMessageAsync`
and `OnChunkReceivedAsync` are now the primary virtuals, and
`ReadAsymmetricMessageAsync` / `WriteAsymmetricMessageAsync` carry the two
private-key operations on that path — the signature and the decryption. Encryption
and verification use the peer's public key and stay synchronous, because they are
always local.

A software key declares neither facet, so every one of those awaits completes
synchronously and the ordering of everything around them is unchanged.

## Two deadlocks, and what they were

Both were found with a secured loopback fixture, which is the coverage that was
missing: the existing loopback tests use `SecurityPolicies.None`, so they exercise
none of the asymmetric path and stayed green throughout.

**Detached work started inline.** The channel started its writes 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 called
`HandleWriteComplete`, which entered the gate, and blocked on a gate that very
thread was holding. The client sent its OpenSecureChannel request and never
processed the response; the peer simply timed out. Writes are now queued, so the
disclaimer only ever runs on genuinely detached work.

**A synchronous gate handle held across an await.** `Enter()` records the
acquiring thread so an inline completion callback can re-enter. 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. Fixed in 9cf1184 along with a regression test.

## Structure

`ReadAsymmetricMessage` is refactored into `ReadAsymmetricMessageSender`,
`SelectEndpointForAsymmetricMessage` and `FinishReadAsymmetricMessage`, which the
synchronous and asynchronous paths share. Duplicating two hundred lines of
signature, padding and endpoint-selection logic was not an acceptable way to add
an await to it.

`AsymmetricMessage` and `AsymmetricWriteResult` exist because an asynchronous
method cannot have `out` parameters.

The receive loop no longer ends silently: an exception escaping chunk dispatch is
reported through `OnTransportError` instead of stopping the channel with no
report at all, which is how the first deadlock presented.

## Still synchronous, and why

Service faults and the `Reconnect` override, because both are reached from
synchronous call sites — `SendServiceFault` alone has eight, and `Reconnect` is
public. Certificate, certificate request and revocation list signing cannot be
made asynchronous at all, because `X509SignatureGenerator.SignData` is invoked by
.NET's own builders. All three are recorded in the documentation rather than left
to be rediscovered.

## Compatibility

The synchronous `HandleIncomingMessage`, `OnChunkReceived` and the
`WriteAsymmetricMessage` overload taking `out byte[] signature` are `[Obsolete]`
but still work: the default `HandleIncomingMessageAsync` calls the synchronous
one, so a subclass outside this stack that overrides only that keeps working.
`MigrationGuide.md` covers the move.

Verified: `dotnet build UA.slnx -c Release` → 0 warnings, 0 errors on all six
target frameworks. Core 4266 on net10.0 and 4191 on net48; Server integration
4048 of 4053; Client integration 2123 — every suite at its recorded baseline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Closes #4210, though not in the shape the issue asked for. Investigating it
produced a finding that is worth recording more than an implementation would
have been.

## The key cannot stay in a device, and that is not fixable here

The issue asked for a handle-based variant of `IPubSubSecurityPolicy` so a PubSub
key could stay inside a TPM, HSM or key service.

`GetSecurityKeys` (Part 14 §8.3.2) returns **raw key bytes over the wire**. By the
time a publisher has a key, that key is in process memory — so the property #4192
achieved for client and server cannot be achieved for PubSub through the SKS pull
profile at all. That is a property of the specification, not of this stack, and no
API added here would change it.

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. The conclusion is documented next to the feature so it is not rediscovered.

## What is achievable, and is delivered

**The operations are pluggable.** The per-message AES-CTR and HMAC-SHA-256 a
publisher and subscriber apply now route through `ISymmetricCryptoProvider` when
one is registered for `CryptoPurpose.ChannelSymmetric`, so a validated module
performs them. That is the same requirement #4207 was added for, reaching the
other half of the stack.

`IPubSubSecurityPolicy` is **not** modified. Adding members would break every
implementation of it; the provider is supplied through the policy constructor
instead, and the singletons remain for the platform path. A provider that does not
declare the algorithms a policy needs is ignored rather than used, so a
configuration mistake does not stop publishing.

`PlatformSymmetricCryptoProvider` gained AES counter mode to support this. The
seam should have covered it regardless — `SymmetricEncryptionAlgorithm` names
`Aes128Ctr` and `Aes256Ctr` — and it is exercised by a round trip that is not a
whole number of blocks and by a check that the counter advances per block, since
a counter that failed to carry would silently reuse key stream.

**Key lifetime is bounded.** Unpacking an SKS response copied the signing key,
encrypting key and nonce into intermediate arrays, and `ByteString.Create` copies
again, so those intermediates were plain key material left in the heap for the
lifetime of the process. They are now zeroed.

## Resolution happens once

The policies resolve the provider when they are constructed, in the dependency
injection registration, and hold it. Nothing consults a registry per message —
this is the hot path for a publisher.

## A correction

An earlier note in the plan claimed `PubSubSecurityKey.Dispose` might skip
zeroization for a `ByteString` that is not array-backed. That was checked and is
wrong: `ByteString.Create` copies into an owned array, so the zeroization always
applies. The note is corrected rather than left to mislead.

## Tests

`PubSubPolicyProviderTests` proves a registered provider is actually called, that
it is ignored when it cannot serve the algorithms, and — the assertion that
matters most — that it produces **byte-identical** output to the platform. A
validated module that disagreed with the platform could not interoperate with
publishers that do not use one. Writing it caught a real defect: the decrypt path
reported an encryption to the provider, because counter mode is its own inverse
and the policy simply delegated.

Verified: `dotnet build UA.slnx -c Release` → 0 warnings, 0 errors on all six
target frameworks. PubSub 1346 of 1347; Core 4268 on net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

Code coverage

Coverage gate passed.

Check Result Threshold
✅ Project line rate 86.46% (216696/250640 lines) >= 70.00%
✅ Project branch rate 76.16% >= 60.00%
✅ Patch coverage 81.85% (1908/2331 changed lines) >= 75.00% (> 100 changed lines)
ℹ️ Baseline delta (advisory) +12.86 pp 73.60% recorded
Uncovered changed lines
  • src/Opc.Ua.Core/Security/Crypto/CryptoCompliance.cs: 211, 212, 306, 307, 308
  • src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryChannel.Rsa.cs: 201, 246
  • src/Opc.Ua.Server/Session/Session.cs: 742
  • src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryChannel.Asymmetric.cs: 628, 629, 630, 631, 632, 633, 634, 635, 636, 638, 639, 640, 641, 684, 705, 706, 708, 709, 712, 714, 716, 717, 720, 721, 723, 729, 731, 733, 735, 736, 739, 740, 742, 743, 745, 747, 752, 769, 770, 771, 772, 774, 775, 776, 777, 778, 779, 791, 805, 806, 818, 820, 821, 822, 831, 833, 843, 1337, 1341, 1364, 1366, 1367, 1370, 1371, 1372, 1373, 1374, 1375, 1376, 1378, 1379, 1380, 1381, 1382, 1383, 1384, 1385, 1386, 1531, 1533, 1534, 1535, 1538, 1572, 1573, 1574
  • src/Opc.Ua.Core/Security/Crypto/CryptoProviderAuditor.cs: 210, 211, 213, 216, 217, 218, 219, 220
  • src/Opc.Ua.Core/Security/Certificates/RsaUtils.cs: 362, 363, 381, 383, 384, 386, 388, 391, 392, 396, 397, 399, 400, 401, 403, 405, 406, 407, 409, 410, 411, 415, 416, 417, 418, 420, 422, 423, 424, 427, 428, 430, 434, 437
  • src/Opc.Ua.Client/Session/Session.cs: 4758
  • src/Opc.Ua.Core/Security/Crypto/PlatformSymmetricCryptoProvider.cs: 205, 207, 214, 249, 250, 251, 268, 269, 301, 460
  • src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs: 463, 572, 731, 752, 1029, 1030, 1041, 1042, 1043, 1044, 1046, 1066, 1088, 1089, 1212, 1242, 1252, 1436, 1446, 1449, 1451, 1452, 1454, 1457, 1459, 1460, 1464, 1467, 1468, 1471, 1472, 1473, 1474, 1477, 1480, 1482, 1483, 1484, 1485, 1487, 1488, 1489, 1490, 1491, 1494, 1496, 1498, 1501, 1503, 1506, 1820
  • src/Opc.Ua.Core/Security/SecurityPolicies.cs: 142, 399, 401, 404, 405, 406, 407, 436, 441, 469, 470, 471, 472, 501, 502, 509, 510, 613, 614, 615, 616, 659, 667, 668, 673, 674, 680, 681, 682, 684, 685, 686, 687, 782, 906, 911, 949, 952, 957, 964, 1011, 1016, 1042, 1129, 1144, 1522
  • src/Opc.Ua.PubSub/Security/Policies/PubSubAes128CtrPolicy.cs: 182, 183, 184, 200, 201, 202
  • src/Opc.Ua.PubSub/Security/Policies/PubSubSecurityPolicyRegistry.cs: 94
  • src/Opc.Ua.Core/Stack/Tcp/TcpReverseConnectChannel.cs: 150, 172, 218
  • src/Opc.Ua.PubSub.Diagnostics/Dissection/PubSubOfflineDissector.cs: 328
  • src/Opc.Ua.PubSub/Security/PubSubSecurityWrapperResolver.cs: 177
  • src/Opc.Ua.Core/Security/Crypto/PlatformKeyDerivationProvider.cs: 80
  • src/Opc.Ua.Core/Stack/Tcp/TcpListenerChannel.cs: 519, 665, 666, 668, 840
  • src/Opc.Ua.Client/CoreClientUtils.cs: 309
  • src/Opc.Ua.Core/Security/Certificates/CryptoUtils.cs: 488, 500, 501, 512, 513, 534, 536, 537, 538, 540, 549, 551, 552, 554, 592, 593, 594, 602, 603, 604, 607, 609, 612, 614, 618, 986, 987, 1098, 1099, 1100, 1101, 1102, 1103, 1104, 1105, 1187, 1188, 1189, 1190, 1191, 1192, 1193, 1194, 1196, 1197, 1258, 1259, 1260, 1261, 1262, 1263, 1264, 1265, 1345, 1346, 1347, 1348, 1349, 1350, 1351, 1352, 1354, 1355, 1667
  • src/Opc.Ua.Core/Security/Crypto/OpcUaCryptoBuilderExtensions.cs: 138, 143, 172
  • src/Opc.Ua.Core/Stack/Tcp/ChannelGate.cs: 184
  • src/Opc.Ua.Core/Stack/Types/X509IdentityTokenHandler.cs: 245, 246, 247
  • src/Opc.Ua.Core/Stack/Tcp/ChannelAsyncOperation.cs: 442, 443, 445, 446, 447, 448, 449, 450, 451, 452, 453, 454, 455, 456, 457, 458, 459, 460, 461, 462, 463, 464, 465, 466
  • src/Opc.Ua.Core/Security/SecurityPolicyInfo.cs: 68
  • src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryChannel.cs: 557, 670, 847, 849, 851, 857, 858, 859, 860, 861, 1010, 1014, 1015, 1025, 1029, 1129, 1131, 1132
  • src/Opc.Ua.PubSub/Security/Sks/InMemoryPubSubKeyServiceServer.cs: 285
  • src/Opc.Ua.PubSub/Security/Policies/PubSubAes256CtrPolicy.cs: 151, 152
  • src/Opc.Ua.Core/Stack/Tcp/TcpServerChannel.cs: 320, 444, 450, 451, 552, 637, 661, 763, 790, 791, 798, 936, 937, 1067, 1128, 1129, 1251, 1341, 1379, 1410
  • src/Opc.Ua.Core/Security/Crypto/CryptoProviderOutput.cs: 87
  • src/Opc.Ua.Core/Security/SecurityPolicyConfiguration.cs: 69
  • src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryChannel.Symmetric.cs: 300, 302, 305, 306, 307, 308, 309, 310, 318, 350, 352, 355, 357, 360, 375, 376, 377, 378, 379, 380
  • src/Opc.Ua.Configuration/ApplicationConfigurationBuilder.cs: 1370

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

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

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.24668% with 577 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.47%. Comparing base (64d2834) to head (d0bcca3).

Files with missing lines Patch % Lines
....Ua.Core/Stack/Tcp/UaSCBinaryChannel.Asymmetric.cs 60.40% 86 Missing and 13 partials ⚠️
...c/Opc.Ua.Core/Security/Certificates/CryptoUtils.cs 63.02% 62 Missing and 26 partials ⚠️
src/Opc.Ua.Core/Security/SecurityPolicies.cs 84.22% 46 Missing and 42 partials ⚠️
...c/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs 60.71% 51 Missing and 4 partials ⚠️
src/Opc.Ua.Core/Security/Certificates/RsaUtils.cs 7.69% 34 Missing and 2 partials ⚠️
src/Opc.Ua.Core/Stack/Tcp/ChannelAsyncOperation.cs 0.00% 24 Missing ⚠️
src/Opc.Ua.Core/Stack/Tcp/TcpServerChannel.cs 76.00% 20 Missing and 4 partials ⚠️
...c.Ua.Core/Stack/Tcp/UaSCBinaryChannel.Symmetric.cs 42.85% 20 Missing and 4 partials ⚠️
src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryChannel.cs 73.23% 18 Missing and 1 partial ⚠️
...Security/Crypto/PlatformSymmetricCryptoProvider.cs 87.87% 10 Missing and 6 partials ⚠️
... and 27 more
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4232      +/-   ##
==========================================
- Coverage   80.84%   80.47%   -0.38%     
==========================================
  Files        1816     1823       +7     
  Lines      249346   250640    +1294     
  Branches    43313    43501     +188     
==========================================
+ Hits       201577   201694     +117     
- Misses      32654    33801    +1147     
- Partials    15115    15145      +30     
Flag Coverage Δ
actions 80.47% <75.24%> (-0.38%) ⬇️

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

Files with missing lines Coverage Δ
...lient/Identity/ClientIdentityProviderExtensions.cs 81.50% <100.00%> (ø)
.../Session/Subscription/ClassicSubscriptionEngine.cs 76.87% <100.00%> (-6.54%) ⬇️
...ore.Diagnostics/Dissection/OfflineSecureChannel.cs 93.61% <100.00%> (ø)
src/Opc.Ua.Core/Schema/ApplicationConfiguration.cs 72.89% <100.00%> (ø)
src/Opc.Ua.Core/Security/Audit.cs 66.66% <100.00%> (ø)
...ore/Security/Certificates/CertificateIdentifier.cs 59.49% <100.00%> (-0.51%) ⬇️
src/Opc.Ua.Core/Security/Certificates/Nonce.cs 76.66% <100.00%> (-0.83%) ⬇️
...ore/Security/Certificates/SecurityConfiguration.cs 76.03% <100.00%> (-1.62%) ⬇️
...pc.Ua.Core/Security/Crypto/CryptoProviderFacets.cs 100.00% <100.00%> (ø)
src/Opc.Ua.Core/Security/Crypto/CryptoPurpose.cs 90.90% <100.00%> (+0.90%) ⬆️
... and 50 more

... and 46 files with indirect coverage changes

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

@marcschier
marcschier marked this pull request as ready for review August 10, 2026 15:47
Copilot AI lite review requested due to automatic review settings August 10, 2026 15:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Implements the three crypto-offboarding follow-ups by adding pluggable symmetric/key-derivation/RNG facets, making key RSA/ECDsa paths awaitable (without changing default sequencing for software keys), and routing PubSub per-message crypto through the same symmetric provider seam while tightening key-material lifetime.

Changes:

  • Added ISymmetricCryptoProvider, IKeyDerivationProvider, and IRandomSource facets (with platform implementations) plus resolution helpers and compliance checks for unserved operation purposes.
  • Refactored UA-TCP secure channel receive/write pipeline to support await in the open/renew path via ChannelGate, async message handling, and async RSA decrypt/sign hooks (IAsyncRsaKey/IAsyncEcdsaKey).
  • Updated PubSub AES-CTR policies to optionally use a resolved symmetric provider and zeroed intermediate SKS-unpacked key buffers; added extensive tests/benchmarks and docs/migration notes.

Reviewed changes

Copilot reviewed 47 out of 47 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/Opc.Ua.PubSub.Tests/Security/Policies/PubSubPolicyProviderTests.cs Adds tests proving PubSub policies route crypto through a symmetric provider and match platform output.
tests/Opc.Ua.Core.Tests/Stack/Transport/UaSCBinaryClientChannelDeterministicTests.cs Updates deterministic client-channel tests for async incoming-message handling.
tests/Opc.Ua.Core.Tests/Stack/Transport/TcpServerChannelDeterministicTests.cs Updates deterministic server-channel tests for async incoming-message handling.
tests/Opc.Ua.Core.Tests/Stack/Transport/TcpServerChannelBufferTests.cs Updates buffer tests to await async chunk dispatch.
tests/Opc.Ua.Core.Tests/Stack/Tcp/ChannelGateTests.cs Adds coverage for ChannelGate exclusion, re-entrancy, and inherited-context hazards.
tests/Opc.Ua.Core.Tests/Security/Crypto/SymmetricCryptoProviderTests.cs Adds tests for symmetric/key-derivation/random seams, resolution rules, and byte-identical interoperability.
tests/Opc.Ua.Core.Tests/Security/Crypto/SymmetricChannelCryptoBenchmarks.cs Adds benchmark variant measuring the provider-routed path.
tests/Opc.Ua.Aot.Tests/CryptoProviderAotTests.cs Adds AOT/trimming coverage for facet discovery and platform facet functionality.
src/Opc.Ua.PubSub/Security/Sks/SksKeyResponse.cs Clears intermediate plaintext key buffers after unpacking SKS responses.
src/Opc.Ua.PubSub/Security/Policies/PubSubAes256CtrPolicy.cs Adds optional symmetric provider injection for AES-CTR/HMAC operations.
src/Opc.Ua.PubSub/Security/Policies/PubSubAes128CtrPolicy.cs Adds optional symmetric provider injection for AES-CTR/HMAC operations.
src/Opc.Ua.PubSub/DependencyInjection/OpcUaPubSubBuilderExtensions.cs Resolves a symmetric provider once in DI and constructs PubSub policies with it.
src/Opc.Ua.Core/Stack/Types/X509IdentityTokenHandler.cs Switches identity-token signing to async signature creation.
src/Opc.Ua.Core/Stack/Types/UserNameIdentityTokenHandler.cs Switches username token decryption to async decrypt.
src/Opc.Ua.Core/Stack/Types/IssuedIdentityTokenHandler.cs Switches issued-token decryption to async decrypt.
src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryClientChannel.cs Replaces monitor locking with ChannelGate and makes handshake/open paths awaitable.
src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryChannel.Symmetric.cs Resolves/holds symmetric + key-derivation facets and routes symmetric ops through them.
src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryChannel.Rsa.cs Adds async RSA decrypt path to avoid blocking on network-backed keys.
src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryChannel.cs Introduces async receive dispatch, async message handling hooks, and queues writes to avoid gate inheritance issues; obsoletes sync overrides.
src/Opc.Ua.Core/Stack/Tcp/TcpServerChannel.cs Makes server open response path async and replaces monitor locking with ChannelGate.
src/Opc.Ua.Core/Stack/Tcp/TcpReverseConnectChannel.cs Routes reverse-connect receive pipeline through async chunk dispatch + ChannelGate.
src/Opc.Ua.Core/Stack/Tcp/TcpListenerChannel.cs Replaces monitor locking with ChannelGate across listener-channel state transitions.
src/Opc.Ua.Core/Stack/Tcp/ChannelGate.cs Adds ChannelGate (async-enterable, re-entrant) as replacement for channel monitor lock.
src/Opc.Ua.Core/Stack/Tcp/ChannelAsyncOperation.cs Changes detached completion callback scheduling to avoid inheriting gate context.
src/Opc.Ua.Core/Stack/Tcp/AsymmetricMessage.cs Adds async-friendly result structs replacing out parameters.
src/Opc.Ua.Core/Security/Crypto/PlatformSymmetricCryptoProvider.cs Adds platform implementation for symmetric crypto operations (incl. AES-CTR).
src/Opc.Ua.Core/Security/Crypto/PlatformRandomSource.cs Adds platform implementation for random source facet.
src/Opc.Ua.Core/Security/Crypto/PlatformKeyDerivationProvider.cs Adds platform implementation for key derivation facet.
src/Opc.Ua.Core/Security/Crypto/PlatformCryptoProvider.cs Extends platform provider to implement the new operation facets and adds KeyDerivation capability.
src/Opc.Ua.Core/Security/Crypto/ISymmetricCryptoProvider.cs Defines the symmetric operation facet interface.
src/Opc.Ua.Core/Security/Crypto/IRandomSource.cs Defines the RNG facet interface.
src/Opc.Ua.Core/Security/Crypto/IKeyDerivationProvider.cs Defines the key-derivation facet interface.
src/Opc.Ua.Core/Security/Crypto/IAsyncAsymmetricKey.cs Defines opt-in async RSA/ECDsa key facets for non-blocking network-backed operations.
src/Opc.Ua.Core/Security/Crypto/CryptoPurpose.cs Adds CryptoPurpose.KeyDerivation.
src/Opc.Ua.Core/Security/Crypto/CryptoProviderFacets.cs Adds facet resolution helpers that return null for platform/default fast paths.
src/Opc.Ua.Core/Security/Crypto/CryptoProviderBuilder.cs Sets the process-wide nonce RNG when a random facet is registered.
src/Opc.Ua.Core/Security/Crypto/CryptoProviderAuditor.cs Enhances FIPS-only compliance checks to fail closed on unserved operation purposes.
src/Opc.Ua.Core/Security/Crypto/CryptoCompliance.cs Adds GetUnservedOperationPurposes to detect facetless bindings.
src/Opc.Ua.Core/Security/Constants/SecurityPolicies.cs Adds async decrypt/signature creation helpers to avoid blocking for network-backed keys.
src/Opc.Ua.Core/Security/Certificates/RsaUtils.cs Adds async RSA decrypt helper used by SecurityPolicies.DecryptAsync.
src/Opc.Ua.Core/Security/Certificates/Nonce.cs Adds random-source pluggability and exposes static HKDF derivation helper.
plans/cryptooffboard.md Marks #4207/#4208 done and records PubSub custody finding/approach.
docs/WhatsNewIn2.0.md Documents new seams, awaitable asymmetric path, and PubSub implications.
docs/MigrationGuide.md Adds migration guidance for channel subclasses/obsolete sync hooks.
docs/CryptoProvider.md Documents symmetric facets, async key facets, and PubSub crypto limitations/approach.

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

Comment thread src/Opc.Ua.PubSub/Security/Policies/PubSubAes256CtrPolicy.cs Outdated
Comment thread src/Opc.Ua.PubSub/Security/Policies/PubSubAes128CtrPolicy.cs Outdated
Comment thread docs/CryptoProvider.md
Comment thread docs/CryptoProvider.md Outdated
Comment thread docs/MigrationGuide.md Outdated
Comment thread docs/MigrationGuide.md Outdated
Comment thread docs/MigrationGuide.md Outdated
Comment thread plans/cryptooffboard.md Outdated
Comment thread src/Opc.Ua.Core/Stack/Tcp/ChannelGate.cs
Comment thread src/Opc.Ua.Core/Security/Crypto/PlatformSymmetricCryptoProvider.cs Dismissed
marcschier and others added 6 commits August 10, 2026 19:07
Addresses four findings from review of the crypto off-board follow-ups.

ChannelGate.EnterAsync lost the holder on the contended path. TakeOwnership
wrote the AsyncLocal from inside AwaitEntryAsync, 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 no thread was recorded. Any nested
Enter then blocked forever on a gate its own context held - reachable
remotely through HandleIncomingMessageAsync's error path into
ForceChannelFault. The holder is now published from the caller's frame before
the wait and its depth raised once the wait completes, which is visible
because the object is shared.

BeginWriteMessage dispatched each write with Task.Run. 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 and cannot
start before their predecessor finishes, while still running off the caller's
stack so HandleWriteComplete does not re-enter the gate inline. A secured
loopback test with 60 concurrent multi-chunk requests fails without this and
passes with it.

CryptoProviderConfiguration was never applied outside tests, so
AddCryptoProvider(configure) was inert in DI. The registry now applies every
registered configuration when it is first resolved.

PubSubSecurityWrapperResolver defaulted to the provider-less static policy
singleton, so the provider-backed bundles registered for PubSub were never
used and a FipsOnly deployment got false assurance. The resolver now selects
from the registered policies; both the container and PubSubApplicationBuilder
supply theirs.

CryptoProviderBuilder.Use published a policy-scoped random source as the
process-wide nonce source. Only an unscoped binding does so now.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
A write that fails outside its own error handling would leave a faulted
task in the chain until the next write replaced it, surfacing later as an
unobserved task exception. The trailing continuation observes it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
A faulting listener channel sends a UASC Error message describing why, then
closes the transport as its next statement. That worked because the write
was started on the caller's stack, so the bytes reached the socket before
the close. Queuing the write to fix the write-path deadlock broke it: the
close now races ahead and discards the error, and the client sees
BadConnectionClosed instead of the certificate status it was told to expect.
Nine SecurityCertValidationTests cases assert exactly that status.

The terminal error is written on the caller's stack again, through a path
that keeps the inherited entitlement to re-enter the gate, because that
re-entry is the caller's own. Normal traffic keeps the ordered queue.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Converting the asymmetric read to async replaced its out parameters with a
returned record, because an async method cannot have out parameters. That
lost an ownership guarantee nobody stated: 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, so a certificate that failed validation was neither disposed nor
audited - nine leaked instances, one per rejection case, which the
Core.Security leak detector fails the run on.

The parsed certificate is handed to the caller before anything that can
reject the message, restoring both the disposal and the audit on the client
and the server paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
The previous commit sent that message on the caller's stack but let it keep
the caller's gate entitlement, so its continuation could resume on another
thread sharing the caller's holder and report completion through
HandleWriteComplete - which the client channel implements by entering the
gate. The shared holder's depth accounting then never reached zero, the
semaphore was never released, and a channel's receive loop blocked on it
until the server could not stop. Opc.Ua.Features.Tests hung at teardown.

The path now disclaims the inherited context and reports nothing: a channel
that is already faulted is owed nothing beyond returning its buffer. With no
gate access left in it, starting the send inline is safe and the bytes still
reach the socket before the caller closes it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
The permit was released only when the holder's depth returned to zero. That
depth lives in an AsyncLocal, so it is shared with every context forked from
the owner's while it holds the gate - a timer callback whose timer was
created inside the region, or any other work started without disclaiming.
Such a context entering the gate raises the shared depth, and the owner's
own exit then sees a non-zero depth and returns without releasing. Nothing
ever releases it afterwards: the nested exit is not the owner. Every later
entry on that channel blocks forever, which is how Opc.Ua.Features.Tests
came to hang 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 that another context can move.
Ordinary nesting is unaffected: the inner scope never releases and the outer
one is the owner.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
@marcschier
marcschier force-pushed the marcschier/crypto-offboard-followups branch from 47a6d34 to 6482f1c Compare August 10, 2026 21:18
marcschier and others added 3 commits August 11, 2026 00:11
Writes are chained so that chunks reach the transport in the order their
sequence numbers were assigned. Reporting a completed write has nothing to
do with that order, but it sat inside the chain, so a write could not start
until its predecessor's completion callback had finished - and the client
channel implements that callback by entering the channel gate. Every write
therefore queued behind a gate that 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, so the chain covers only what
ordering actually requires.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
The changed-lines coverage gate read 72% against a 75% floor. The gap was
new code no test reached: the authenticated ciphers and signature lengths of
the platform symmetric provider, its argument validation for counter mode,
the facade PlatformCryptoProvider delegates through, the key derivation
provider's rejection path, and the gate's cancellation and Releaser equality
paths.

Measured with the pipeline's own script against the same base: 72.02% ->
76.81% on changed lines.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Review feedback:

- Fixed the mis-indented `if (m_provider != null)` in both PubSub CTR
  policies.
- Removed the sentence flagged in the PubSub key custody section.
- Removed `UaSCBinaryChannel.DataLock`, `HandleIncomingMessage` and
  `OnChunkReceived` outright rather than leaving them `[Obsolete]`, as
  asked. The synchronous `WriteAsymmetricMessage` overload that took no
  chain is gone; the remaining one is `private protected`, so it is off
  the surface a channel outside this assembly can use while the fault and
  reconnect paths that cannot await keep working. `HandleIncomingMessageAsync`
  no longer defers to a synchronous override, so an existing override must
  move rather than silently keep occupying a thread. The MigrationGuide
  sections now describe removals and show how to move an override.
- Trimmed plans/cryptooffboard.md to outstanding work only: everything
  delivered is documented in docs/CryptoProvider.md and recorded in the
  issues, so repeating it as "done" only invited it to drift.

Benchmarked the channel gate, as asked. `ChannelGateBenchmarks` measures it
against the monitor it replaced: an uncontended acquisition is 40.7 ns and
96 B where the monitor was 13.1 ns and nothing, a nested one adds 5 ns and
nothing, and the asynchronous entry is 48.7 ns and completes synchronously.
The per-message work it is taken around is 10,495 ns, so the gate is under
half a percent of it - not a throughput regression. Value task reuse and
IValueTaskSource were investigated and rejected with the reason recorded on
the type: the uncontended path allocates no task at all, so there is none to
pool; the 96 B is the execution context copy an AsyncLocal write makes. A
pooled token is also consume-once while `Releaser` is a copyable struct, and
recycling the holder is unsafe for the same reason re-entrancy works - a
forked context keeps a reference and would read a recycled holder as its own
entitlement.

Registrable security policies, which the review asked to support:

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:

- The four lookup tables were built by reflecting over the fields of
  `SecurityPolicies` and `SecurityPolicyInfo`. They are now an explicit
  table, which removes the last reflection in Security/Constants and is
  therefore also a trimming and Native AOT improvement.
- `SecurityPolicyInfo` is constructible from another assembly, and carries
  the metadata the helpers need, so `IsPlatformSupportedName`,
  `GetDefaultUris`, `MapSecurityPolicyToCertificateTypes` and
  `GetCurveFromCertificateTypeId` read one table instead of each being
  hand-written. Adding a policy is one entry rather than five edits.
- `SecurityPolicies.Register` and `AddSecurityPolicy` register a policy at
  runtime, directly or through the container. Registration is copy-on-write
  so the read path stays lock-free, and shadowing a built-in policy requires
  `replaceExisting: true` and is reversible.
- The dead `#if CURVE25519` conditional is gone from Security/Constants.
  `RegisterLightsUpCurvePoliciesFromOutsideCore` is the acceptance test the
  issue asked for: it lights up both curve profiles from outside Core.

Registration makes a policy advertised and resolvable; the cryptography
behind it still comes from a provider, which is stated in the documentation
rather than implied.

Fixes #4206

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
@marcschier marcschier changed the title Pluggable symmetric cryptography, an awaitable asymmetric path, and PubSub crypto offboarding (#4207, #4208, #4210) Pluggable symmetric cryptography, an awaitable asymmetric path, registrable security policies, and PubSub crypto offboarding (#4206, #4207, #4208, #4210) Aug 11, 2026
marcschier and others added 3 commits August 11, 2026 22:46
ChannelGate is re-entrant, and it has to track ownership in an AsyncLocal
to be so: a holder that awaits releases its thread, so thread identity
cannot serve. That AsyncLocal has been the single largest source of
concurrency defects in the channel, because a logical context is
inherited -- work 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 at each of eight sites with no compiler check.

Re-entrancy is the only reason the ownership tracking exists, so remove
the requirement rather than the mechanism. Every path that took the gate
while already holding it now calls a lock-free Core method:

  ForceChannelFault  -> ForceChannelFaultCore (3 overloads)
  Shutdown           -> ShutdownCore
  ForceReconnect     -> ForceReconnectCore
  CompleteConnect    -> CompleteConnectCore

Three regions also had to stop running foreign code under the lock:

  - The RequestReceived dispatch is deferred until the gate is released.
    The handler is application code whose continuation calls SendResponse
    on the same stack; it was also serialising the whole channel on
    request processing time. A throwing handler is caught, so it cannot
    unwind into the receive loop and return the chunk buffer twice.

  - OnTransportError no longer wraps HandleSocketError. That virtual
    tears the channel down, which notifies the listener, which disposes
    the channel -- and disposal takes the gate. Each override now takes
    the gate for the state it actually mutates.

  - The channel's callback setters and EndpointDescription no longer
    lock. Each publishes a single reference, for which a volatile write
    is sufficient, and the reverse-hello completion invokes its callback
    inline while the gate is held (AsyncResultBase.OperationCompleted),
    which would otherwise deadlock in TcpTransportListener.

Disposal deliberately stops taking the gate. It is reached from
ChannelFaulted while the gate is held, where the old re-entrant
acquisition excluded nothing anyway, and Dispose is not thread-safe by
contract.

SaveIntermediateChunk, GetSavedChunks and DoMessageLimitsExceeded now
carry the caller's locking context, because the client reaches them both
from the open path (gate held) and the response path (not held) and has
to pick the matching teardown. Without it an oversized OpenSecureChannel
response deadlocked the receive loop against itself.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
With no path entering the gate twice, the ownership tracking has nothing
left to do. The AsyncLocal<Holder?>, the Holder, the thread-identity
fallback, LeaveInheritedContext, IsHeldByCurrentContext, TakeOwnership
and the depth counting all go, and the gate becomes a SemaphoreSlim(1,1)
behind a Releaser -- which is what it would have been from the start had
the code it replaced not relied on a monitor's re-entrancy.

The eight LeaveInheritedContext() calls go with it. They were the
residual contract: every fire-and-forget path had to disclaim an
inherited entitlement it never wanted, enforced only by review.

The call shape at all 32 sites is unchanged, so the gate keeps its
disposal semantics and the diff stays in the type itself.

Entering twice from one flow now blocks, which is the ordinary contract
of a mutex, rather than silently running two flows inside a region whose
purpose is to keep them apart.

An uncontended acquisition now costs what the semaphore costs and
allocates nothing, against ~41 ns and 96 bytes before -- the 96 bytes
were the execution context copy that writing an AsyncLocal makes. That
was never the motivation: the guarded per-message crypto is ~10,500 ns.
The defect history was.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
The gate tests were largely about re-entrancy and inherited contexts, so
they described a type that no longer exists. Replace them with the
properties that now matter: exclusion across both entry modes, that an
uncontended asynchronous entry does not suspend, that re-entry blocks by
contract, and -- the one that used to be the hazard -- that work started
while the gate is held is excluded without having to opt out.

Add a regression test for the deadlock the mixed locking context
produced: an oversized OpenSecureChannel response reaches
DoMessageLimitsExceeded with the gate held.

Drop GateEnterReentrant from the benchmarks; there is nothing to measure.

Document in the migration guide that the gate is no longer re-entrant,
that HandleSocketError, NotifyMonitors and CompleteReverseHello overrides
now run under a non-re-entrant lock, and that SaveIntermediateChunk,
GetSavedChunks and DoMessageLimitsExceeded carry a gateHeld argument.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
marcschier and others added 2 commits August 12, 2026 07:18
Remove the redundant nested block left in CompleteConnectCore,
ShutdownCore and ForceReconnectCore where the `using (Gate.Enter())`
scope used to be, and de-indent their bodies.

Put the TcpListenChannelLog4 call back on one line with m_logger, and
drop the redundant parentheses and the dangling open paren in the
Basic128Rsa15 check in ForceChannelFaultCore.

No behaviour change: `git diff -w` is only the six removed braces.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Two tests hold the gate across their assertions and released it with a
bare call afterwards, so a failing assertion stranded the permit and left
the task under test blocked until the 30s timeout -- reporting a hang
instead of the assertion that failed.

Both now release in a finally. A using is still not usable here: the
gate has to be released before the detached task is awaited to
completion, and Releaser is a struct whose Dispose is not idempotent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Comment thread plans/cryptooffboard.md Outdated
Comment thread src/Opc.Ua.Core/Security/Constants/SecurityPolicies.cs Outdated
Comment thread src/Opc.Ua.Core/Security/SecurityPolicyRegistry.cs Outdated
Comment thread src/Opc.Ua.Core/Security/Constants/SecurityPolicyRegistry.cs
Comment thread src/Opc.Ua.Core/Security/Crypto/IAsyncRsaKey.cs
Comment thread src/Opc.Ua.Core/Security/Crypto/IRandomSource.cs Outdated
Comment thread src/Opc.Ua.PubSub/Security/Policies/PubSubAes128CtrPolicy.cs Outdated
Comment thread src/Opc.Ua.Types/Polyfills/System.Runtime.CompilerServices.cs Outdated
marcschier and others added 4 commits August 12, 2026 09:40
Both were introduced when the RequestReceived dispatch was moved out of
the guarded region, and both show up as timing-dependent integration
failures rather than as anything the unit suites can see.

The chunk buffers the request was decoded from were returned to the pool
by ProcessRequestMessage's finally *before* the handler ran. The decoder
does not copy everything it reads, so the request could be overwritten
underneath the handler by the next message on the same channel. Ownership
of the collection now transfers to the pending dispatch and it is
released once the handler returns, which is the lifetime the inline
dispatch had.

TcpListenerChannel.HandleSocketError also lost its mutual exclusion. It
used to run under the gate OnTransportError held; that gate had to go so
the virtual is not called with the channel's own lock held, but the
graceful branch calls ChannelClosed(), which moves the channel to Closed
and notifies the listener. Racing that against an in-flight request on
the same channel leaves the session it belongs to behind. The branch
takes the gate itself now; the faulting branch already did, through
ForceChannelFault.

Also restores the line break the brace cleanup in 66476be collapsed in
OnScheduledHandshakeAsync.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
TcpServerChannel.Dispose stopped taking the gate, because it is reached
from ChannelFaulted with the gate already held and the gate is no longer
re-entrant. That leaves it able to run while a request holds the gate,
and the Kestrel-hosted listener does exactly that: it signals closure and
then disposes the channel asynchronously, so the disposal can overtake the
open-secure-channel request still in flight on it.

The request adopts the client certificate only when the channel does not
already have one. If disposal ran between that check and the assignment,
it released the certificate it saw -- which was not this one -- and the
adopted certificate was never released. The Sessions suite's leak
detector caught it on Linux, reporting four certificates created and not
disposed.

Disposal now records that it ran, and the adopting path releases the
certificate itself when it sees that. Both are ordinary volatile
accesses, so neither side has to take a lock that would deadlock.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Delete plans/cryptooffboard.md. The work it planned has landed and
nothing references it.

Move SecurityPolicyRegistry out of Security/Constants: it is a registry,
not a constant. SecurityPolicyConfiguration moves with it and into its
own file.

Rename IAsyncAsymmetricKey.cs to IAsyncRsaKey.cs after the interface it
declares, and split IAsyncEcdsaKey into its own file.

Rename IRandomSource to ISecureRandomSource. Every implementation must
be cryptographically secure, and the old name did not say so.

Remove the IsExternalInit polyfill this PR added. Nothing needs it: the
solution builds clean on all six target frameworks without it, including
netstandard2.0 and net472/net48.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
SecurityPolicies was a static class owning a process-wide snapshot. #4206
made the set mutable through SecurityPolicies.Register, but the state was
still one global and every lookup was a static method reading it. Two
applications hosted together shared a policy list, tests had to mutate
global state and undo themselves, and the API read as free functions on a
constants class when every one of them operates on the content of a
registry.

The policy set is now an object. ISecurityPolicyRegistry carries the
surface consumers call -- Find, Policies, Register, the lookup and
default-URI helpers, the certificate-type helpers, and the cryptography --
and the sealed SecurityPolicyRegistry implements it. Members rather than
extension methods, so they can reach the registry's own logger.

The registry takes an ITelemetryContext and creates its logger from it,
which is what lets Encrypt, Decrypt, DecryptAsync and the signature
helpers drop their ILogger parameter. Each registry seeds its own copy of
the built-in policies, so registering in one does not reach another;
SecurityPolicyRegistry.Default carries the built-ins for the paths that
run before any container exists, and behaviour is unchanged when an
application registers nothing.

AddSecurityPolicy now applies to the registry the container owns rather
than to global state, and AddSecurityPolicyRegistry registers one without
contributing a policy.

SecurityPolicies keeps its 28 URI constants and the platform-support
predicates SecurityPolicyInfo is built from, which is the large majority
of references to the type and none of them change. It becomes a sealed
class with a private constructor: a static class cannot be an extension
receiver, and the migration shim declares extension(SecurityPolicies)
members.

The nine methods that shipped in 1.05.378 -- GetUri, GetDisplayName,
IsValidSecurityPolicyUri, GetDisplayNames, GetDefaultUris,
GetDefaultEccUris, GetDefaultDeprecatedUris, Encrypt and Decrypt -- are
restored by Opc.Ua.MigrationAnalyzer.Core as [Obsolete] static extension
members forwarding to Default, under new analyzer rule UA0024. Core keeps
no forwarders. The methods added in 2.0 and never shipped are simply
moved. The shimmed Encrypt and Decrypt accept the 1.05.378 ILogger
argument and ignore it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Comment thread src/Opc.Ua.Core/Security/Constants/SecurityPolicies.cs
Comment thread src/Opc.Ua.Core/Security/SecurityPolicyRegistry.cs Outdated
Comment thread src/Opc.Ua.Core/Security/Constants/SecurityPolicies.cs Outdated
Comment thread src/Opc.Ua.Core/Security/SecurityPolicyRegistry.cs Outdated
Comment thread src/Opc.Ua.Core/Stack/Tcp/AsymmetricMessage.cs
Comment thread src/Opc.Ua.Core/Stack/Types/IssuedIdentityTokenHandler.cs Outdated
Comment thread src/Opc.Ua.PubSub/Security/Policies/PubSubAes128CtrPolicy.cs Outdated
marcschier and others added 3 commits August 12, 2026 17:02
Two findings from a security review of the provider seam, both new on this
branch and both silent in the way that matters: the deployment believes a
validated module is performing the cryptography while it is not.

FIPS compliance was audited by testing for the presence of the facet
interface only. Every consumer applies a second, stricter test --
Supports(algorithm) -- and falls back to the platform when it returns
false, which the facet contracts explicitly permit. A module that carries
ISymmetricCryptoProvider but answers false for the algorithms of a
negotiated policy therefore passed the FipsOnly audit at startup while the
platform performed every message, with nothing logged and a clean
compliance report. That is precisely the failure CryptoProviderAuditor
documents itself as existing to prevent.

GetUnservedOperations now checks the algorithms of every policy the
application offers and names the (purpose, policy, algorithm) triples that
would fall through, so the audit refuses to start and says why. Resolving
to the platform provider itself reports nothing: the platform performing
the platform's work is not a shortfall.

DeriveKey and GetBytes return void and were never checked. A provider that
no-ops, fills part of the buffer, or swallows a failure is
indistinguishable from one that succeeded, and the buffer becomes channel
signing keys, encryption keys, IVs and nonces. The platform paths they
replace cannot fail this way -- Utils.PSHA returns its array and
RandomNumberGenerator throws -- so only the provider path was exposed.
Because both ends of a channel usually run the same image, both would
derive the same dead keys, the handshake would complete, and traffic would
flow with no confidentiality and forgeable integrity.

The buffer is now stamped before the call and checked after it, and output
left untouched or zeroed is refused with BadSecurityChecksFailed. This
cannot prove the output is good, which no caller-side check can; it fails
closed on output that is provably unusable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Fold the registry into SecurityPolicies. The constants class and the
registry class were two halves of one idea: constants describing policies,
and an object owning the set of them. SecurityPolicyRegistry is renamed
SecurityPolicies and absorbs the URI constants and the platform-support
predicates SecurityPolicyInfo is built from, so there is one sealed class
holding both, with Default as the fallback singleton.

ISecurityPolicyRegistry moves to its own file. SecurityPolicies.cs and
SecurityPolicyInfo.cs move out of Security/Constants, which keeps
AdditionalParameterNames.cs and SecurityConstants.cs and so stays.

The identity token handlers take an optional ISecurityPolicyRegistry and
fall back to Default when none is passed, so a token's security policy URI
resolves against the policies that application offers. The X509 handler's
private clone constructor carries it too; without that a cloned handler
would silently revert to the fallback.

PubSub follows the same shape. IPubSubSecurityPolicyRegistry and a sealed
PubSubSecurityPolicyRegistry with a Default singleton replace the static
lookup table, and the platform-backed policy instances become internal:
taking one directly would bypass a configured symmetric crypto provider,
so the registry is the only way in. Opc.Ua.PubSub.Diagnostics.Tests and
Opc.Ua.PubSub.Udp.Tests gain InternalsVisibleTo, which the PubSub tests
already had.

Note for anyone reading the PubSub registry: Default is initialized before
any static field declared after it, so the built-in set is built by a
method rather than held in a field. A field there is null when Default
runs, which is a TypeInitializationException on first use.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Three conflicts, all in the migration analyzer's rule tables, and all the
same cause: master's lock-removal work (#4222, #4188) claimed UA0024
through UA0028 while this branch had already taken UA0024 for the
SecurityPolicies move.

Master's numbering wins because it is on the base branch and its rules
already ship in AnalyzerReleases; this branch's rule is renumbered to
UA0029. That is a rename of an unreleased identifier, so nothing that
exists elsewhere refers to the old number. Both sides' rules are kept in
full -- DiagnosticIds, DiagnosticDescriptors and AnalyzerReleases carry
master's five lock rules and this branch's UA0029 -- and the shim
attribute, obsolete messages and the MigrationGuide anchor were renumbered
with it.

Everything else merged cleanly, including the files where both sides had
touched the same types: ApplicationConfiguration, DataGenerator and the
server Session.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
marcschier and others added 6 commits August 13, 2026 09:14
`PublishRequestCountAsync` failed on the Windows net10.0 leg with 207
outstanding publish requests against a cap of 50.

`QueueBeginPublish` was a check-then-act: it read `GoodPublishRequestCount`,
compared it against the desired count, and issued a request if it was below.
It runs on every publish completion, so completions that overlap each read
the same value, each conclude they are below the cap, and each issue,
overshooting by up to the number of concurrent completions per round. The
read is also lagging, because 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, but this PR made the race far easier to hit. The
server used to hold the channel gate across request processing, which
serialised every request on a channel and let publish completions arrive one
at a time. Dispatching `RequestReceived` outside the gate lets them complete
concurrently.

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 atomic.
The reservation is released when the request completes, when the send is
declined, or when it throws.

`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.

`ConcurrentPublishReEvaluationDoesNotExceedDesiredRequestCount` reproduces
the overshoot deterministically against a mocked context whose count lags
the send the way the session's does: 64 barrier-released completions issued
64 requests where 5 were desired, and issue at most 5 with the reservation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
Every build job started failing on NU1903: SSH.NET 2025.1.0 has a known
high severity vulnerability, GHSA-q939-rpr3-3284. Nothing in the dependency
graph changed - the advisory was published, and it affects all versions up
to and including 2025.1.0.

SSH.NET is not referenced anywhere in the repository. It arrives
transitively through Testcontainers.Kafka, so the fix is a central
transitive pin, matching the existing System.Net.Http and
System.Text.RegularExpressions entries above it.

2026.0.0 is the first patched version and clears the audit: the solution
builds with 0 warnings and 0 errors with NuGetAudit left enabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
master landed the same GHSA-q939-rpr3-3284 remediation independently, so
carrying it here too produced two identical PackageVersion entries in the
merge commit and failed restore with NU1506.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7a68ca72-83e8-496c-b2f5-3df4c718320b
…wups' into marcschier/crypto-offboard-followups
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants