- [x] `ChannelQuotas` + `UaSCUaBinaryChannel` carry an `ISecurityPolicyRegistry` (falling back to `SecurityPolicies.Default`) - [x] Transport channel/listener settings carry the registry and feed every `ChannelQuotas` construction site - [x] `ClientChannelManager` and `ServerBase` source the registry - [x] `Session`, `DefaultSessionFactory`, `ChannelManagerSessionFactory` and `ManagedSession` carry it - [x] Identity token handler injection points made load-bearing (`UserIdentity`, `AsTokenHandler`, default identity providers, `IdentitySelectionContext`) - [x] `SessionSecurityPolicyHelper`, `ClientIdentityProviderExtensions`, `CoreClientUtils`, `OfflineSecureChannel`, `OpcUaSecurityKeyServiceClient` - [x] Client and server DI wiring resolve `ISecurityPolicyRegistry` - [x] `IPubSubSecurityPolicyRegistry` registered in DI and threaded through the PubSub application/SKS paths - [x] Channel asymmetric crypto (padding, sign, verify, ECC classification) resolves from the negotiated policy instead of a hard-coded policy URI switch - [x] Unit tests for the registry carriers plus an integration test that negotiates a policy registered through `AddSecurityPolicy` over a real loopback handshake - [x] `docs/CryptoProvider.md` documents how the registry reaches the channel and the session - [x] Full solution build (`UA.slnx`, net10.0) and full `Opc.Ua.Core.Tests` run (4351 passed) are green - [ ] Broader integration suites (`Opc.Ua.Sessions.Tests`, `Opc.Ua.Server.Tests`, `Opc.Ua.PubSub.Tests`) were still running when time ran out - [x] Review feedback addressed: preserved original CLR signatures, routed factory channel creation through public registry-aware overloads, failed closed for unsupported asymmetric encryption, and added a provider contract for custom session factories - [x] Focused review-feedback tests pass (28 tests across Client, Core, and Core.Diagnostics) - [x] Security review: hardened SKS custom-policy admission against weak algorithms and incoherent RSA/ECC key-exchange metadata; final specialist review found no remaining vulnerabilities <!-- START COPILOT CODING AGENT SUFFIX --> - Fixes #4250 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com> Co-authored-by: Marc Schier <mschier@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: romanett <romanett98@gmail.com>
30 KiB
Crypto provider
The stack performs its cryptography with the .NET platform by default. This document describes how to replace that: with another library, a remote service, or hardware such as a TPM, an HSM, a PKCS#11 token or a cloud key service — and how to keep private keys inside those devices so they never exist in process memory.
Nothing here changes behaviour until you configure it. A deployment that configures nothing behaves exactly as it did before the provider model existed.
Contents
- The short version
- Holding a private key in a device
- Selecting a provider per purpose and per policy
- Generating a key inside a device
- Certificate renewal with a device held key
- Validation status, compliance and audit
- What can and cannot be claimed about FIPS
- Substituting the symmetric primitives
- Using a key served over a network
- PubSub
- Contributing a security policy
- Limitations
The short version
System.Security.Cryptography.RSA and ECDsa are abstract classes, and the stack routes every
private-key operation through them. Implementations backed by hardware and cloud services already exist —
RSACng over a TPM key storage provider, Pkcs11Interop, Azure's RSAKeyVault. The stack therefore does
not define its own signing interface, because doing so would make all of those unusable.
What the provider model adds is the part the platform does not express:
| Concern | Type |
|---|---|
| Which operations a provider can serve | ICryptoProvider, CryptoCapability |
| Which provider serves which operation | ICryptoProviderRegistry, CryptoPurpose |
| The symmetric, derivation and random operations themselves | ISymmetricCryptoProvider, IKeyDerivationProvider, ISecureRandomSource |
| What may be said about the module behind it | CryptoValidationStatus |
| How strictly that is enforced | CryptoCompliancePolicy |
| Where a new key comes from | IKeyPairGenerator |
| Where a certificate and its key live | ICertificateStoreProvider, ICertificateStore |
Holding a private key in a device
A device-held key is represented by a detached private key: the Certificate holds the key alongside
the X509Certificate2 rather than inside it.
// The device generated the key and will not give it up. Only a handle is available.
RSA deviceKey = OpenKeyInDevice("app-instance");
using Certificate publicOnly = Certificate.FromRawData(certificateDer);
Certificate certificate = publicOnly.CopyWithDetachedPrivateKey(deviceKey);
// HasPrivateKey is true, GetRSAPrivateKey() returns the device key, and the
// stack signs and decrypts with it exactly as it would with a software key.
Using a PKCS#11 token
The optional OPCFoundation.NetStandard.Opc.Ua.Security.Pkcs11 package supplies a certificate store
backed by a hardware token, smart card or HSM. It is never referenced by Opc.Ua.Core, so applications
that do not use one are unaffected.
services.AddOpcUa()
.AddPkcs11CertificateStore(new Pkcs11TokenOptions
{
ModulePath = "/usr/lib/softhsm/libsofthsm2.so",
TokenLabel = "opcua",
PinProvider = () => secretStore.Read("token-pin")
})
.AddPkcs11CryptoProvider(CryptoPurpose.ApplicationInstanceKey);
Stores are then addressed with an RFC 7512 URI, so an existing configuration moves to a token by changing only the store path:
pkcs11:token=opcua;object=server?module-path=/usr/lib/softhsm/libsofthsm2.so
The store binds the token key with CopyWithDetachedPrivateKey for the reason below. Signing supports
PKCS#1 v1.5 and PSS, decryption supports OAEP and PKCS#1 v1.5, and ECDSA is supported on the curves the
token implements. Revocation lists are not held on a token, and objects are provisioned with the vendor's
tools rather than through the store.
On Linux the module is loaded through DllImport("libdl"), and glibc 2.34 folded libdl into libc.
Distributions that ship only the libdl.so.2 ABI stub need libc6-dev (or an equivalent libdl.so
symlink) or the first call fails with DllNotFoundException. That is a property of the interop library
rather than of this package.
Why not CopyWithPrivateKey?
X509Certificate2.CopyWithPrivateKey cannot be used for this. On Windows the certificate layer has fast
paths only for RSACng and RSACryptoServiceProvider; for any other implementation it falls back to
exporting the private parameters, which a non-extractable key refuses by definition:
| Approach | Result on Windows |
|---|---|
CopyWithPrivateKey(custom RSA) |
CryptographicException |
CopyWithPrivateKey(RSACng, ExportPolicy=None) |
works |
CopyWithDetachedPrivateKey(...) |
works, on every platform |
CertificateRequest.CreateSelfSigned is affected for the same reason, since it calls
CopyWithPrivateKey internally.
A CNG-backed key may use either route. Everything else — PKCS#11, cloud key services, bespoke
implementations — must use CopyWithDetachedPrivateKey.
Lifetime
The detached key is shared by every handle created with AddRef() and is disposed once, with the last
handle, unless you pass ownsPrivateKey: false. Each call to GetRSAPrivateKey() returns an independent
non-owning view, so the usual using around the returned object is safe and does not destroy the device
key.
Exporting such a certificate as PKCS#12 throws rather than silently producing a file without a key.
Selecting a provider per purpose and per policy
Real deployments mix sources. Selection is therefore a resolution over three discriminators — purpose, security policy and certificate type — with the most specific registration winning:
(purpose, policy) → purpose → registered default → platform
services.AddOpcUa()
.AddCryptoProvider(crypto => crypto
.For(CryptoPurpose.ApplicationInstanceKey).Use(tpmProvider)
.For(CryptoPurpose.KeyAgreement).Use(tpmProvider)
.For(CryptoPurpose.UserIdentityKey).Use(keyVaultProvider)
.For(CryptoPurpose.CertificateIssuance, SecurityPolicies.ECC_nistP384).Use(hsmProvider));
Purposes:
| Purpose | Used for |
|---|---|
ApplicationInstanceKey |
Signing and decryption while a secure channel is opened |
UserIdentityKey |
Proving possession of a user certificate when a session is activated |
KeyAgreement |
Ephemeral key agreement for the elliptic curve policies |
CertificateIssuance |
Signing certificates, certificate requests and revocation lists |
ChannelSymmetric |
Per-message symmetric encryption and signing |
KeyDerivation |
Deriving the channel and session key material from a shared secret |
RandomNumberGeneration |
Nonces and other random material |
CryptoPurpose is a value type with well-known instances, not an enum, so you can define your own.
A provider bound to a purpose it does not declare is skipped, and resolution falls through to the next candidate. A configuration mistake therefore fails close to its cause instead of deep inside a handshake.
Registration is explicit. There is no assembly scanning: it would make the effective security configuration depend on what happens to be loaded, and it is incompatible with trimming and ahead-of-time compilation.
Where resolution happens
Resolve when something is bound — a channel opening, a certificate loading, a key being generated —
and hold the result. The registry is not meant to be consulted per message. ChannelQuotas.CryptoProviders
carries the registry to a channel, which resolves once and keeps the result for its lifetime, in the same
way it caches the security policy on its token.
Generating a key inside a device
IKeyPairGenerator decides where the key behind a new application instance certificate comes from:
var application = new ApplicationInstance { KeyPairGenerator = new TpmKeyPairGenerator() };
The builder arrives with the subject, subject alternative names and lifetime already set. An implementation only chooses where the key comes from and how the certificate is signed.
An implementation backed by hardware cannot call the parameterless CreateForRSA() or
CreateForECDsa(), because those generate a key in software. It must supply the public key generated in
the device with SetRSAPublicKey / SetECDsaPublicKey, and sign with an X509SignatureGenerator that
calls back into the device.
For the Part 12 push flow the equivalent seam is IPushCertificateKeyGenerator, which is already
registered through dependency injection and can be replaced the same way.
Certificate renewal with a device held key
Only one of the two Part 12 flows works with a key that cannot leave its device:
| Flow | Compatible? |
|---|---|
CreateSigningRequest(RegeneratePrivateKey = true) then UpdateCertificate with no private key |
✅ the key is generated in the device and only a certificate request leaves it |
UpdateCertificate with a private key supplied |
❌ raw key material is pushed into the server |
Between the two calls the regenerated key has to be staged. HardwarePendingCertificateKeyStore does this
for device-held keys: there is nothing to export, because the device already is the durable store, so only
the association between the pending certificate and its scope is kept. A software key is declined, so the
caller falls back to a store that knows how to protect exportable material.
Validation status, compliance and audit
Every provider declares what may be said about the module behind it:
| Level | Meaning |
|---|---|
FipsValidated |
The provider names a validation certificate for its module |
FipsCapablePlatform |
It defers to platform cryptography, which is validated when the OS is configured for it |
Uncertified |
It carries no validation — a third-party library or a bespoke implementation |
Unknown |
It declined to say. Treated as uncertified, and always reported |
CryptoCompliancePolicy decides how strictly this is enforced:
| Policy | Effect |
|---|---|
Permissive (default) |
Existing behaviour. Nothing is filtered and nothing is warned about |
WarnOnUncertified |
Every provider carrying no validation is reported |
FipsOnly |
Providers carrying no validation are refused and the server does not start |
using var auditor = new CryptoProviderAuditor(registry, telemetry, CryptoCompliancePolicy.FipsOnly);
auditor.Report(); // writes the effective configuration to the log
auditor.ThrowIfNotCompliant(); // refuses to continue if anything is uncertified
Metrics are published regardless of policy, because they are pull-based and cost nothing when nobody reads them:
| Instrument | Meaning |
|---|---|
opc.ua.crypto.providers |
One measurement per provider, tagged with name and validation level |
opc.ua.crypto.providers.uncertified |
How many providers in use carry no validation |
What can and cannot be claimed about FIPS
.NET holds no cryptographic module validation certificate of its own. It calls through to the module the operating system supplies — CNG on Windows, OpenSSL on Linux, CoreCrypto on macOS. Whether that module is running in a validated mode is a property of how the machine is configured, not of this stack.
What can honestly be said: with FipsOnly, the stack performs no cryptography outside the platform
modules, and those modules are validated when the operating system is configured for it.
What cannot be said: that the stack itself is validated.
These algorithms are not FIPS-approved and are enabled by default:
| Algorithm | Note |
|---|---|
| ChaCha20-Poly1305 | Not a NIST-approved algorithm |
| Brainpool P-256r1 / P-384r1 | Not in SP 800-186 |
| Curve25519 / X25519 | Not an approved curve |
| SHA-1 and P-SHA1 (Basic128Rsa15, Basic256) | Deprecated for new signatures by SP 800-131A |
net472 and net48 additionally use the BouncyCastle.Cryptography package for elliptic curve
certificate building. That package is not validated — the validated Bouncy Castle product is a
separate, commercially licensed distribution — so those target frameworks cannot make a FIPS claim at
all.
What FipsOnly actually enforces at startup
CryptoProviderAuditor.ThrowIfNotCompliant() refuses to start when a provider bound to an operation
would not perform it. Carrying the facet is necessary but not sufficient: Supports(algorithm) is
consulted again at the point of use, and a provider that answers false there is bypassed in favour of
the platform for that algorithm alone. So the audit checks the algorithms of every policy the
application actually offers, and names what would fall through:
The compliance policy requires validated cryptography to perform every operation, but the
provider resolved for these cannot perform them and the platform would be used instead:
ChannelSymmetric (ChaCha20Poly1305) for ...#ECC_nistP256_ChaChaPoly, KeyDerivation (HkdfSha256) for ...
A module that covers AES-CBC but not ChaCha20-Poly1305 is therefore caught before it can run — where a facet-only check would have reported it fully compliant while the platform performed every message on any policy negotiating the algorithm it lacks. Nothing is reported when the platform provider is the one resolved: the platform performing the platform's work is not a shortfall.
Narrow the offered policy set, or extend the module, rather than lowering the compliance policy.
Output a provider did not produce is refused
IKeyDerivationProvider.DeriveKey and ISecureRandomSource.GetBytes return void, so a module that
no-ops, fills part of the buffer, or swallows an internal failure is indistinguishable from one that
succeeded — and the buffer it was handed becomes channel signing keys, encryption keys, initialization
vectors and nonces. The platform paths these replace cannot fail this way: Utils.PSHA returns the array
it built and RandomNumberGenerator throws.
That matters most for exactly the deployments this seam exists for. A network- or hardware-served module can be transiently offline, and because both ends of a channel usually run the same image, both would derive the same dead key material and the handshake would complete — traffic flowing with no confidentiality and forgeable integrity, invisible to the operator and to the peer.
So the buffer is stamped before the call and checked after it. Output left untouched or zeroed is
rejected with BadSecurityChecksFailed rather than used. This cannot prove the output is good — no
caller-side check can — it fails closed on output that is provably unusable. Implementations must fill
the whole span or throw.
Substituting the symmetric primitives
The asymmetric operations are pluggable because RSA and ECDsa are already the right abstraction. The
symmetric ones are not: the platform offers nothing that covers the block cipher, the authenticated
cipher and the message authentication code together, so three interfaces declare those operations
directly.
| Facet | Covers |
|---|---|
ISymmetricCryptoProvider |
AES-CBC, AES-GCM, ChaCha20-Poly1305 and the HMAC signatures |
IKeyDerivationProvider |
P_SHA1, P_SHA256, HKDF-SHA256, HKDF-SHA384 |
ISecureRandomSource |
Nonces and other random material |
A provider opts in by implementing one alongside ICryptoProvider and declaring the matching purpose.
They are separate interfaces rather than members of ICryptoProvider, so a provider written before they
existed still compiles, and a provider that can serve only some of them says so.
public sealed class ValidatedModule : ICryptoProvider, ISymmetricCryptoProvider
{
public string Name => "AcmeFIPS";
public CryptoValidationStatus Validation => new(
CryptoValidationLevel.FipsValidated, "Acme Cryptographic Module", "CMVP #1234");
public ArrayOf<CryptoCapability> Capabilities { get; } =
new(new[] { new CryptoCapability(CryptoPurpose.ChannelSymmetric) });
// ... the operations
}
services.AddOpcUa()
.AddCryptoProvider(crypto => crypto
.For(CryptoPurpose.ChannelSymmetric).Use(module)
.For(CryptoPurpose.KeyDerivation).Use(module)
.For(CryptoPurpose.RandomNumberGeneration).Use(module));
The consumer this exists for is a validated software module that must perform every operation, not hardware offload. A device round trip per message would destroy throughput, and nothing here makes that viable.
RandomNumberGeneration is the one purpose with process-wide reach: nonces are created from many places
that have no container in scope, so an unscoped binding also becomes the process's nonce source. A
binding made for a single security policy — .For(purpose, policyUri).Use(module) — deliberately does
not, because it would otherwise redirect nonce generation for every other policy as well.
What it costs when you do not use it
Nothing, by construction. CryptoProviderFacets returns null both when no registry is configured and
when resolution lands on the platform provider, because the platform facets perform exactly the code the
channel would otherwise run inline. A null facet tells the channel to take its existing path, so the
per-message code has no interface dispatch at all unless a provider was registered.
Resolution happens once, in CalculateSymmetricKeySizes, and the result is held for the life of the
channel. SymmetricChannelCryptoBenchmarks measures both paths — EncryptSignThenDecryptVerify is the
baseline and EncryptSignThenDecryptVerifyThroughProvider is the same work through the seam — so the
cost of the indirection is measured rather than assumed.
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 case, and under FipsOnly
CryptoProviderAuditor.ThrowIfNotCompliant() refuses to start rather than run on cryptography the
operator did not ask for.
Using a key served over a network
RSA and ECDsa are synchronous contracts, and they are .NET's rather than this stack's, so a key
backed by a cloud key service occupies a thread for the whole of every call. Rather than replace those
contracts — which would make every ready-made hardware and cloud implementation unusable — an
implementation may also declare an asynchronous path:
public sealed class KmsRsa : RSA, IAsyncRsaKey
{
// The synchronous members are still implemented, and are what the paths
// that are not yet asynchronous will use.
public async ValueTask<byte[]> SignHashAsync(
ReadOnlyMemory<byte> hash,
HashAlgorithmName hashAlgorithm,
RSASignaturePadding padding,
CancellationToken ct = default)
=> await m_kms.SignAsync(hash, hashAlgorithm, padding, ct);
public async ValueTask<byte[]> DecryptAsync(
ReadOnlyMemory<byte> data,
RSAEncryptionPadding padding,
CancellationToken ct = default)
=> await m_kms.DecryptAsync(data, padding, ct);
}
The stack finds the facet by type test, so a key that does not implement it is unaffected and the asynchronous paths complete synchronously for it — ordering, and everything that depends on it, is unchanged.
| Path | Asynchronous? |
|---|---|
| Secure channel open and renew | ✅ |
| User identity token signing and decryption, session activation | ✅ |
| Service faults, and the synchronous reconnect handoff | ❌ both are reached from synchronous call sites |
| Certificate, certificate request and revocation list signing | ❌ by construction — X509SignatureGenerator.SignData is called by .NET internals |
PubSub
The per-message cryptography a publisher and subscriber apply — AES-CTR and
HMAC-SHA-256, per Part 14 §7.2.4.4.3.1 — routes through
ISymmetricCryptoProvider when one is registered, so a validated module performs
it:
services.AddOpcUa()
.AddCryptoProvider(crypto => crypto
.For(CryptoPurpose.ChannelSymmetric).Use(module))
.AddPubSub(...);
The policies resolve the provider once, when they are constructed, and hold it;
nothing consults a registry per message. A provider that does not declare the
algorithms a policy needs is ignored rather than used, so a configuration mistake
does not stop publishing. IPubSubSecurityPolicy is unchanged — a provider is
supplied through the constructor, so implementations of that interface outside
this stack are unaffected.
The wrapper resolver selects the bundle it wraps with from the registered
policies rather than from a static default, which is what makes the registration
above take effect. Outside the container, PubSubApplicationBuilder does the
same with the policies it holds, and WithSecurityPolicySelector overrides the
choice per connection:
var module = new ValidatedModule();
new PubSubApplicationBuilder(telemetry)
.WithSecurityPolicySelector(
(_, _) => new PubSubAes256CtrPolicy(module))
// ...
.Build();
What device custody can and cannot mean here
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 property the client and server side achieve — the key never leaves the
device — cannot be achieved for PubSub through the SKS pull profile. That is
a property of the specification, not of this stack.
Introducing a wrapped-key envelope would change what is on the wire and break interoperability with third-party key services and publishers, so it is deliberately not done.
What is achievable, and is supported:
- The operations can be performed by a validated module, as above.
- The key material can come from somewhere other than the SKS.
IPubSubSecurityKeyProvideris the seam; an implementation may derive per-token keys from a long-lived secret that stays in a device, so only the derived material is in memory. - Its lifetime is bounded.
PubSubSecurityKeyzeroizes on disposal, the key ring disposes keys as it retires them, and the intermediate copies made while unpacking an SKS response are cleared rather than left in the heap.
Contributing a security policy
The policy set is no longer fixed at compile time. A provider that implements a profile this stack does
not ship — a national or vendor profile, or one added by a later specification — can make it visible at
runtime by constructing a SecurityPolicyInfo and registering it:
using IDisposable registration = SecurityPolicies.Register(
new SecurityPolicyInfo("urn:vendor:SecurityPolicy#CustomProfile", "CustomProfile")
{
PlatformSupport = () => true,
SupportedCertificateTypes = [ObjectTypeIds.RsaSha256ApplicationCertificateType],
IsDefault = true
});
or through the builder, alongside the provider that performs its cryptography:
services.AddOpcUa()
.AddCryptoProvider(crypto => crypto
.For(CryptoPurpose.ChannelSymmetric, "urn:vendor:SecurityPolicy#CustomProfile").Use(module))
.AddSecurityPolicy(customPolicy);
A registered policy is discoverable through the same API as a built-in one — GetInfo, GetUri,
GetDisplayName, GetDisplayNames and the default-URI helpers — because those are now driven from one
table rather than from reflection over the constants. Registering a URI or name that already exists
throws unless replaceExisting: true is passed, which makes shadowing a built-in policy deliberate and
reversible: disposing the returned registration restores what was there before.
Resolving the policy set
Those lookups are members of ISecurityPolicyRegistry, which is the object that owns the policy set.
Resolve it to work against the policies this application registered:
public sealed class MyEndpointPicker(ISecurityPolicyRegistry policies)
{
public string[] Offered => policies.GetDisplayNames();
}
AddSecurityPolicy registers the registry for you; call AddSecurityPolicyRegistry() when you want to
resolve one without contributing a policy of your own.
The identity token handlers take one too, so a token's security policy URI resolves against the policies that application offers rather than the process-wide fallback:
var handler = new UserNameIdentityTokenHandler(username, password, policies);
Passing nothing keeps the previous behaviour and uses SecurityPolicies.Default.
Reaching the secure channel and the session
An application composed through the builder gets its registry threaded through the stack: the client
channel manager, the session factory, the sessions it creates and the identity providers they select
all resolve policies against it, and a server's listeners are opened with it. A policy registered with
AddSecurityPolicy is therefore negotiable by that application's channels — the secure channel resolves
the handshake policy, its key sizes, its asymmetric padding and its signature algorithm from the same
registry.
Constructing the transport directly carries the registry the same way:
await channel.OpenAsync(
endpointUrl,
new TransportChannelSettings
{
Description = endpoint,
Configuration = endpointConfiguration,
SecurityPolicyRegistry = policies
},
ct);
TransportListenerSettings and ChannelQuotas expose the same property for the server side, and
Session, DefaultSessionFactory and ClientChannelManager take one on construction. Leaving any of
them unset falls back to SecurityPolicies.Default, so an application that configures nothing is
unaffected.
ManagedSession sources the registry from the session factory it was given, so a session opened from
the container already carries it. Building one by hand takes it through the fluent API:
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
.UseEndpoint(endpoint)
.UseSecurityPolicies(policies)
.ConnectAsync(ct);
The registry then reaches both the sessions the builder creates and the channels they open, including the ones re-created on reconnect.
The registry a container builds is its own. A policy registered by one application is not visible to
another hosted in the same process, and it is not visible to SecurityPolicies.Default. That
fallback carries exactly the built-in set and is what the paths that run before any container exists —
configuration loading, most obviously — resolve their policies through:
string? uri = SecurityPolicies.Default.GetUri("Basic256Sha256");
Construct a registry directly when you want an isolated set, which is also what makes the policy set testable without touching process-wide state:
using var policies = new SecurityPolicies(telemetry);
using IDisposable registration = policies.Register(customPolicy);
The registry creates its logger from the ITelemetryContext it is given, which is why Encrypt,
Decrypt and the signature helpers take no ILogger argument.
PlatformSupport is what decides whether the policy is offered on the machine it is running on, so a
policy whose algorithms are unavailable is filtered out the same way the built-in ones are. The
ECC_curve25519 and ECC_curve448 profiles are the worked example: they ship in the tree but are not
advertised, and a consumer can light them up from outside without rebuilding the stack — which is what
RegisterLightsUpCurvePoliciesFromOutsideCore asserts.
Registering a policy makes it advertised and resolvable; it does not by itself supply the cryptography behind it. For the two curve profiles above, the in-tree key agreement is still behind a compile-time symbol that no project defines and a BouncyCastle dependency, so a deployment that lights them up supplies the operations through a provider, exactly as the sections above describe. That is the intended division: the policy set says what is offered, the provider says who performs it.
Removing the reflection that used to build these tables also removed the last reflection in
Opc.Ua.Core.Security.Constants, which is why this is also a trimming and Native AOT improvement.
Limitations
- HTTPS with a device-held key does not work on Windows or macOS. SChannel and the macOS Security framework require keys registered with a platform key storage provider. It works on Linux, where the TLS layer dispatches through the managed key. UA-TCP is unaffected on every platform.
- The per-message symmetric path is not offloaded to hardware. Session keys are symmetric and derived per channel token; a device round trip per message would destroy throughput. Hardware is used only for the operations that happen when a channel opens or a session is activated. Substituting a software implementation is supported — see above.
- Certificate issuance cannot be made asynchronous at all.
X509SignatureGenerator.SignDatais called by .NET's ownCertificateRequestand CRL builders, so signing a certificate, a certificate request or a revocation list with a remote key occupies a thread by construction. Service faults and the synchronous reconnect handoff are in the same position, because both are reached from call sites that cannot become asynchronous without a contract break.
Related
- Certificates — how certificates are used in the certificate stores
- CertificateManager — certificate lifecycle and Part 12 push management
- ECC Certificates — the elliptic curve security policies
- Dependency Injection — how features are registered
- Diagnostics — telemetry, audit events and metrics
- Native AOT — why plugin discovery is registration-based