Files
5899354441 Clarify documentation, migration guidance, and SDK selection (#4823)
# Description

Remove stock phrasing and correct misleading or inconsistent technical
documentation. The initial two passes covered 367 Markdown files,
followed by an interactive review of 169 editorial flags. The U5
interpretation originally deferred in that review has since received the
upstream Mantis answer recorded by #4843; remaining server/spec
follow-ups stay explicit in the CTT log.

- Correct package responsibilities, migration steps, API names,
ownership, protocol descriptions and links. Keep bundled migration
guides synchronized. Preserve historical conclusions as historical
records with separate re-check notes.
- Provide at least two rendered TOC levels throughout `docs/`. Convert
appropriate architecture figures to Mermaid without changing code
examples, transcripts or historical quotations.
- Address the October 19 feedback with concrete setup/migration
instructions, corrected certificate paths and PubSub names, shorter
repeated caveats, consistent XML comments, and a full review of the
Concepts guide. Retain #4839's API corrections.
- Integrate #4841's runtime/performance guidance and review
incoming/recent prose. Preserve dated measurements rather than claiming
new benchmark runs; clarify allocation ratios, startup-only processor
settings, JIT warm-up and the benchmark commit's scope. Missing raw
measurement artifacts and repeat ranges remain tracked by #4842.
- Merge master through `e8c5d800a` in `32c6394b7`. Resolve the CTT log
conflict using the updated Mantis states and OPC answers, retaining
historical framing and re-checks for C28/C32/C43.
- Address the latest naming/index feedback in `adb5bf693`: rename the
guides to `docs/WotCon.md` and `docs/WotNodeSetConversion.md`; move the
provider and DataType-declaration contracts into the conversion guide
and remove `docs/WoT.md`. Rename `samples/DI` to `samples/Di` and
`samples/Isa95` to `samples/ISA95`. Add content-index READMEs to Di,
FluentApi, ISA95, Lds and PubSub; update solution entries, project
references, Docker/CI paths, test fixture paths and documentation links
with exact casing.

**WoT guide scope.** This branch retains the detailed conversion guide
that #4758 had replaced with an anchor stub. Conversion limits, verified
document sets, preservation rules and provider ownership now live
together in `WotNodeSetConversion.md`; binding/runtime guidance remains
in `WotBindings.md` and `WotCon.md`. The design-rationale/test-ledger
prose was shortened, the oversized argument-table cell moved into its
section, and existing anchors preserved.

**SDK policy is a separate, explicit user-directed change.**
`global.json` sets a stable minimum of `10.0.400`, `rollForward:
latestMajor`, and `allowPrerelease: false`. Newer major SDKs are
permitted; CI's explicit SDK pins are unchanged. README, GettingStarted,
DeveloperGuide and Recycling instructions reflect the possible analyzer
differences. The image-contract regression fix accepts compatible stable
SDK images instead of requiring exact equality with the floor.

The latest four rename/index threads are resolved after publication.
Three older threads remain open: universal obsolete-removal timing,
unconditional nonce-strength wording, and a blanket Vision NativeAOT
claim. Existing implementation/evidence constraints are explained in
their inline replies. The two-level TOCs remain as requested.

## Validation

- `git diff --check` and staged diff checks: passed. The untouched
upstream AutoID NodeSet retains its provenance bytes, including two
existing license-header trailing spaces.
- `.\.azurepipelines\validate-migration-plugin.ps1`: passed; 15 bundled
migration documents and manifests synchronized.
- Documentation/path validation: **101** documents retain two rendered
TOC levels (**2,228** entries), with idempotent formatting. **52** new
relative links and their anchors passed exact-case checks. No tracked
references to the old guide/sample paths remain. Both relocated
provider-contract sections are preserved in the destination.
- `dotnet sln UA.slnx list`: passed; shows the renamed Di/ISA95 projects
and the existing FluentApi/Lds/PubSub projects. Updated solution and
pump ProjectReference paths also passed exact-case existence checks.
- Latest focused commands, run from the repository root:

```powershell
# Passed: 61 channel send-order and reconnect tests.
dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~UaSCBinaryChannelSendOrderTests|FullyQualifiedName~TcpReconnectKeyAgreementTests' --verbosity minimal

# Passed: 5 pump datasheet tests; builds the renamed pump sample.
dotnet test tests\Opc.Ua.Di.Tests\Opc.Ua.Di.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~PumpDatasheetConformanceTests' --verbosity minimal

# Passed: 4 repository NodeSet path/round-trip tests, including the moved pump models.
dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~RepositorySourceSurvivesSerializedLinkedPartitionsAsync' --verbosity minimal

# Passed: build, zero warnings/errors.
dotnet build samples\ISA95\MinimalIsa95Server\MinimalIsa95Server.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal

# Passed: dependent sample-test project build, zero warnings/errors.
dotnet build tests\Opc.Ua.WotCon.Samples.Tests\Opc.Ua.WotCon.Samples.Tests.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal
```

Earlier focused integration checks passed 2,620 tests across AutoID,
event queues/scheduling, source generation, MCP/package profiles and
WoT. Both reviewed Mermaid diagrams rendered locally without network
access. These checks are not a full solution run.

The full solution has **not** been rerun for this review. Earlier
`dotnet test UA.slnx -p:CustomTestTarget=net10.0 --verbosity quiet` did
not pass: three AOT projects rejected VSTest and 20 Vision tests failed
during graphics initialization with `OutOfMemoryException`. Remaining
long-running tests were stopped at the user's request. A focused Vision
rerun passed 558 tests and reproduced those 20 failures. The earlier
`dotnet test UA.slnx -p:CustomTestTarget=net48 --verbosity quiet` was
blocked by the former SDK pin and was not retried after the policy
change. The user approved proceeding with those limitations documented;
current remote CI is not claimed green.

## Related Issues

- Follow-up #4842 tracks the requested 2.0.2+ benchmark/tuning and
diagnostic-tool documentation refresh. #4841 supplies documentation and
earlier results, not all newly requested measurements.
- #4758 explains the conversion guide's earlier consolidation; the
current provider contracts are now consolidated into
`WotNodeSetConversion.md`.
- #4839 supplies the reviewed API corrections; #4843 supplies the
updated CTT issue record.
- The original prose cleanup and SDK policy were user-requested without
a source issue. No new runtime architecture is introduced by these
documentation edits.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [ ] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [ ] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Roman Ettlinger <romanett98@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:10:55 +02:00

31 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

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.

The provider model covers concerns 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:

flowchart LR
    exact["(purpose, policy)"] --> purpose["purpose"] --> registered["registered default"] --> platform["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. The stack does not scan assemblies because that would make the effective security configuration depend on which assemblies happen to load. Assembly scanning would also conflict 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 declared Uncertified or Unknown are refused; the auditor also checks algorithm coverage for the offered security policies
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 registered regardless of policy. Their measurement callbacks run when an observer collects 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.

FipsOnly admits only providers declared FipsValidated or FipsCapablePlatform and checks their algorithm coverage. It does not validate the stack; verify each selected module, version and operating mode against its validation certificate.

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

net48 additionally uses the BouncyCastle.Cryptography package for elliptic curve certificate building, for the raw ECDH agreement of the ECC security policies and for the AES-GCM and ChaCha20-Poly1305 ciphers of the AEAD policies. Because those steps bypass the provider registry, FipsOnly withholds every ECC policy and every AEAD policy on net48, including the NIST curves and RSA_DH_AesGcm. That package is not the separately validated Bouncy Castle product, so a net48 deployment cannot rely on this package for a FIPS claim.

What FipsOnly actually enforces at startup

When the application calls CryptoProviderAuditor.ThrowIfNotCompliant() at startup, the auditor throws if 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 ...

If a module supports AES-CBC but not ChaCha20-Poly1305, the audit catches it before it runs. A facet-only check could mark the module compliant even when the platform performs every message using a policy that requires the missing algorithm. The audit reports a shortfall only when a non-platform provider is resolved; it does not report the platform performing its own operations.

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. A provider could silently fail by returning without writing output, filling only part of the buffer, or swallowing an internal failure. The caller cannot distinguish those cases from success, and the buffer becomes channel signing keys, encryption keys, initialization vectors, and nonces.

The platform paths these providers replace do not fail this way: Utils.PSHA returns the array it creates, and RandomNumberGenerator throws on failure.

This risk matters most for the deployments that use this seam. A network- or hardware-served module can go offline temporarily. If both ends of a channel run the same image, they could derive the same invalid key material and complete the handshake. Traffic would then lack confidentiality and have forgeable integrity, without either peer detecting the failure.

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 Advanced Encryption Standard (AES) in CBC and GCM modes, ChaCha20-Poly1305, and Hash-based Message Authentication Code (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

When no custom provider facet is selected, the channel uses its existing cryptographic path without provider-interface dispatch per message. CryptoProviderFacets returns null when no registry is configured or when resolution selects the platform provider. A null facet tells the channel to take that existing path.

Resolution happens once, in CalculateSymmetricKeySizes, and the result is held for the life of the channel. Use SymmetricChannelCryptoBenchmarks to compare the paths on the target runtime: EncryptSignThenDecryptVerify runs the baseline and EncryptSignThenDecryptVerifyThroughProvider runs the same work through the provider interface.

A provider that cannot do what it was bound to

Binding a provider to ChannelSymmetric without implementing ISymmetricCryptoProvider would fail silently. Resolution would fall back to the platform, while the deployment could believe its validated module performed the per-message cryptography.

CryptoCompliance.GetUnservedOperationPurposes reports this case. Under FipsOnly, CryptoProviderAuditor.ThrowIfNotCompliant() stops startup rather than run cryptography the operator did not request.

Using a key served over a network

RSA and ECDsa are synchronous .NET contracts. A key backed by a cloud service therefore occupies a thread for each call. The stack keeps these contracts so existing hardware and cloud implementations remain usable. Providers 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, so the registration above takes 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. Therefore, the SKS pull profile cannot keep PubSub keys exclusively on the device. This limitation comes from the specification, not the 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. IPubSubSecurityKeyProvider is 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. PubSubSecurityKey zeroizes key material on disposal. The key ring disposes keys as it retires them. The implementation also clears intermediate copies created while unpacking an SKS response.

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 uses the same discovery API as a built-in policy: GetInfo, GetUri, GetDisplayName, GetDisplayNames, and the default-URI helpers. One table drives these lookups instead of reflection over constants.

Registration throws if the URI or name already exists. Pass replaceExisting: true to replace it. Dispose the returned registration to restore the previous policy, making built-in policy shadowing deliberate and reversible.

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

The builder passes the registry through the application stack. The client channel manager, session factory, sessions, and selected identity providers all resolve policies against it. The server also opens its listeners with that registry.

A policy registered with AddSecurityPolicy is therefore negotiable by the application's channels. The secure channel uses the same registry to resolve the handshake policy, key sizes, asymmetric padding, and signature algorithm.

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 builder passes the registry to the sessions it creates and the channels they open, including channels recreated during 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. Paths that run before any container exists — such as configuration loading — resolve their policies through it:

string? uri = SecurityPolicies.Default.GetUri("Basic256Sha256");

Construct a registry directly when you want an isolated set. You can then test the policy set 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 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 — as RegisterLightsUpCurvePoliciesFromOutsideCore asserts.

Registering a policy makes it advertised and resolvable, but does not supply its cryptography. The in-tree key agreement for the two curve profiles above remains behind a compile-time symbol that no project defines, and it depends on BouncyCastle. A deployment that enables these profiles must supply their operations through a provider, as described in Substituting the symmetric primitives. The policy set says what is offered; the provider says who performs the cryptography.

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.SignData is called by .NET's own CertificateRequest and 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.