Pluggable symmetric cryptography, an awaitable asymmetric path, registrable security policies, and PubSub crypto offboarding (#4206, #4207, #4208, #4210) - #4232
Conversation
`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
Code coverage✅ Coverage gate passed.
Uncovered changed lines
Coverage is above the recorded baseline - consider ratcheting Thresholds live in |
There was a problem hiding this comment.
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, andIRandomSourcefacets (with platform implementations) plus resolution helpers and compliance checks for unserved operation purposes. - Refactored UA-TCP secure channel receive/write pipeline to support
awaitin the open/renew path viaChannelGate, 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.
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
47a6d34 to
6482f1c
Compare
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
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
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
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
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
`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
…ffboard-followups
…wups' into marcschier/crypto-offboard-followups
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,IKeyDerivationProviderandISecureRandomSource, 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, withSymmetricChannelCryptoBenchmarksas the gate.They are facets discovered by type test, not members of
ICryptoProvider, so providers written against the shipped interface still compile.PlatformCryptoProviderimplements all three, which answers the "an interface nothing implements" objection recorded in the issue.CryptoProviderFacetsreturnsnullboth when no registry is configured and when resolution lands on the platform, because the platform facets are the inline code. Resolution happens once, inCalculateSymmetricKeySizes, and is held for the life of the channel.CryptoCompliance.GetUnservedOperationPurposescloses the failure the seam would otherwise introduce: a provider bound toChannelSymmetricwithout 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. UnderFipsOnlythat now refuses to start.#4208 — the asymmetric path is awaitable
RSAandECDsaare .NET's contracts and cannot be replaced without making every ready-made hardware and cloud implementation unusable. Instead an implementation may also declareIAsyncRsaKey/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.
ReadAsymmetricMessageis refactored into shared helpers rather than duplicated — adding anawaitwas not worth two hundred cloned lines of signature, padding and endpoint-selection logic.AsymmetricMessage/AsymmetricWriteResultexist because an asynchronous method cannot haveoutparameters.The channel no longer serialises its state on a monitor. A monitor cannot be held across an
await, and a plainSemaphoreSlimwas not an option at the time: the code relied on re-entrancy in eight places (HandleIncomingMessageholds the lock and callsForceChannelFault, which takes it again;ForceReconnectandShutdowndo the same).ChannelGatereplaces it at 32 sites andDataLockis[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, soLeaveInheritedContext— 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 —ChannelAsyncOperationinvokes 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.SignAsyncrejected the policy that signs nothing. It validated the certificate before the algorithm, soSecurityPolicies.None— which carries no certificate — threw where the synchronousSignreturnsnull.EnterAsynclost the holder when contended (found in review). TheAsyncLocalrecording 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 whileIsHeldByCurrentContextreportedfalse— and any nestedEnter()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
Errordescribing 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 sawBadConnectionClosedinstead of the certificate status it was told to expect. NineSecurityCertValidationTestscases 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
outparameters, so the asymmetric read returns a record instead. That silently dropped an ownership guarantee: anoutparameter reaches the caller even when the method goes on to throw, so the caller'scatchdisposed 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
AsyncLocaland 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.Testshung 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.Classicasserts 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.SignDatais invoked by .NET's own builders. Service faults and theReconnectoverride are reached from synchronous call sites (SendServiceFaultalone 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:
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.IPubSubSecurityPolicyis not modified — new members would break every implementation of it; the provider is supplied through the policy constructor.PlatformSymmetricCryptoProvidergained 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.Createthen 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:
CryptoProviderConfigurationwas applied nowhere outside tests, soAddCryptoProvider(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.PubSubSecurityWrapperResolverselectedPubSubAes256CtrPolicy.Instance, the provider-less static singleton, ignoring the provider-backed bundles registered for it. AFipsOnlydeployment therefore certified while the platform did the work. The resolver now selects from the registered policies; the container andPubSubApplicationBuilderboth supply theirs.A third, smaller one:
CryptoProviderBuilder.Usepublished 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.
SecurityPoliciesandSecurityPolicyInfo. They are one explicit table now, which removes the last reflection inSecurity/Constants— so this is a trimming and Native AOT improvement as much as a feature.SecurityPolicyInfois constructible from another assembly and carries the metadata the helpers need, soIsPlatformSupportedName,GetDefaultUris,MapSecurityPolicyToCertificateTypesandGetCurveFromCertificateTypeIdread that table instead of each being hand-written. Adding a policy is one entry rather than five edits.#if CURVE25519conditional is deleted fromSecurity/Constants.replaceExisting: trueand is reversible by disposing the registration.RegisterLightsUpCurvePoliciesFromOutsideCoreis the acceptance test the issue asked for: it registersECC_curve25519andECC_curve448from 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.
ChannelGatewas 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 anAsyncLocal<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 viaLeaveInheritedContext()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
AsyncLocalthen deleted itself. Every path that re-entered now calls a lock-freeCoremethod (ForceChannelFaultCore,ShutdownCore,ForceReconnectCore,CompleteConnectCore), and three regions stopped running foreign code under the lock: theRequestReceiveddispatch is deferred until the gate is released,OnTransportErrorno longer wraps theHandleSocketErrorvirtual, and the callback setters andEndpointDescriptionpublish a single reference with a volatile write instead of locking — the last is load-bearing, becauseAsyncResultBase.OperationCompletedinvokes the reverse-hello callback inline while the gate is held.ChannelGateis now aSemaphoreSlim(1,1)behind aReleaser;Holder, the thread-identity fallback,LeaveInheritedContext,IsHeldByCurrentContextand all eight disclaimer calls are gone, with the call shape at all 32 sites unchanged.SaveIntermediateChunk,GetSavedChunksandDoMessageLimitsExceededgained agateHeldargument, because the client reaches them both with and without the gate and must pick the matching teardown — without it an oversizedOpenSecureChannelresponse 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
EndpointDescriptionproperty getter taking the gate, and theRequestReceiveddispatch to application code happening inside the guarded region.Two deliberate residuals are documented for reviewers:
Disposeno longer takes the gate (unavoidable — it is reached fromChannelFaultedwith the gate held, where the old re-entrant acquisition excluded nothing anyway), and the deferred request dispatch buildsSecureChannelContextjust after gate release rather than under it.Testing
New: the three seams and their platform implementations, facet discovery and resolution precedence,
FipsOnlyrefusing 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;ChannelGateTestsnow asserts the non-re-entrant contract instead.SecureClientAndTcpListenerKeepChunkOrderUnderConcurrentRequestsAsync— 60 concurrent multi-chunkSignAndEncryptrequests 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
ValueTaskmust reportIsCompletedSuccessfully, which is what makes "ordering is unchanged" checkable rather than merely asserted.SymmetricChannelCryptoBenchmarksgains 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 Release0 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.
ChannelGateTestswas 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/masterbaseline in a second worktree, and fixed at the source. Two hangs were found from--blame-hang-timeoutdumps read withdotnet-dump. One failure that looked like a candidate was confirmed not to be: an intermittentOpc.Ua.Aot.Testsconnect failure, pre-existing onorigin/master(5 of 8 baseline runs failed, versus 3 of 14 here).The publish request overshoot
Subscriptions.Classic.PublishRequestCountAsyncfailed on the Windows net10.0 leg with 207 outstanding publish requests against a cap ofmax(maxServerPublishRequest, subscriptions)= 50. It failed in 2 of 6 pipeline builds on this branch, never onmasterin 13 sampled builds, and never locally in 12 attempts including under deliberate CPU contention.The mechanism is a genuine race.
ClassicSubscriptionEngine.QueueBeginPublishwas a check-then-act: it readGoodPublishRequestCount, compared it againstGetDesiredPublishRequestCount(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 toGoodPublishRequestCountonceAsyncRequestStartedhas 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
RequestReceivedoutside 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.
ConcurrentPublishReEvaluationDoesNotExceedDesiredRequestCountdrives 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.
StartPublishingis 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.Classic32/32 five consecutive runs on net10.0 and once on net48;Opc.Ua.Client.Tests2124 on net10.0 across four consecutive runs and 2127 on net48;Opc.Ua.Subscriptions.Tests604 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.0affected, first patched2026.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.masterlanded the remediation independently while this was being investigated (a central transitive pin to2026.0.0, whichCentralPackageTransitivePinningEnabledexists for). This branch briefly carried its own identical pin, which made the merge commit hold twoPackageVersionentries for SSH.NET and fail restore withNU1506. The duplicate is removed andmasteris merged, so the pin now comes from one place only.The audit was not suppressed:
NuGetAuditstays enabled andNU1903stays an error. Verified after the merge —dotnet restore UA.slnxreports neither NU1903 nor NU1506, anddotnet build UA.slnx -c Releaseis 0 warnings, 0 errors with the audit live.The remaining red check in build 17026 was not from this change either.
aot-ubuntu-latestis the intermittentOpc.Ua.Aot.Testsfailure already noted above —ReconnectSessionAsynctimed out at 30s with 135 of 136 passing. It remains below theorigin/masterbaseline rate (4 of 15 sampled here against 5 of 8 on the baseline), and the managed reconnect paths are green throughout:Opc.Ua.Client.Tests2124 across four consecutive runs andOpc.Ua.Subscriptions.Tests604.Documentation
docs/CryptoProvider.mdgains 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.mdcovers the[Obsolete]channel virtuals andDataLock, and the non-re-entrant gate's contract for channel subclasses.docs/WhatsNewIn2.0.mdis updated.plans/cryptooffboard.mdis 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 theAsyncLocaldeleted. The wrapper/Coresplit 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/Constantsand rewrites the policy lookup tables, soSecurityConfiguration.SupportedSecurityPoliciesand 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 bySecurityConfigurationBuildsExpectedSupportedPolicySet.Related Issues
ISecurityPolicyRegistryfrom DI yet, so every production path still lands on theDefaultfallback. Inject ISecurityPolicyRegistry into Session and the secure channel instead of falling back to the default singleton #4250 tracks threading it throughSessionandUaSCBinaryChannel, which is what makes the injection points already added here load-bearing.Checklist