Skip to content

Pluggable cryptography and hardware-held private keys (#4190) - #4192

Open
marcschier wants to merge 25 commits into
masterfrom
marcschier/crypto-offboard-plan
Open

Pluggable cryptography and hardware-held private keys (#4190)#4192
marcschier wants to merge 25 commits into
masterfrom
marcschier/crypto-offboard-plan

Conversation

@marcschier

@marcschier marcschier commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Description

Makes the stack's cryptographic operations replaceable — by another library, an offboard service, or hardware (TPM 2.0 / HSM / PKCS#11 token / cloud KMS) — with no performance cost in the default configuration, and extends this to certificates so a private key need never exist in process memory.

This started as the research plan in plans/cryptooffboard.md and now carries the implementation. The plan is kept in the branch because it is the design record, and because one of its central assumptions turned out to be wrong in a way worth writing down (below).

20 commits, 83 files, ~11.2k insertions. dotnet build UA.slnx -c Release0 warnings, 0 errors (master currently has 2).

The finding that reshaped this work

The design assumed a hardware key could be attached with X509Certificate2.CopyWithPrivateKey. An empirical probe on Windows / .NET 10 disproved it:

Attempt Result
CopyWithPrivateKey(custom non-exportable RSA) CryptographicException
CopyWithPrivateKey(RSACng, ExportPolicy=None) works
private key held detached beside the certificate works

The Windows certificate pal fast-paths only RSACng and RSACryptoServiceProvider; for anything else it falls back to ExportParameters(true), which a non-extractable key refuses by definition. So a PKCS#11 wrapper, RSAKeyVault, or any offboard provider could not have worked at all — this affects anyone attempting the obvious approach today, not just this PR. CertificateRequest.CreateSelfSigned is affected identically, because it calls CopyWithPrivateKey internally.

The fix is a detached private key: Certificate holds the key alongside the X509Certificate2 in its ref-counted core rather than inside it, which is platform-independent by construction. GetRSAPrivateKey() returns a non-owning view so the shared device key survives the caller's using. This is recorded in §3.3a of the plan.

What is in here

Detached keys and export hardening. CopyWithDetachedPrivateKey, plus nine call sites that assumed a PFX round-trip always succeeds — they now fail loudly instead of silently dropping a key.

The provider model. ICryptoProvider declares capabilities and validation provenance, not operations — RSA/ECDsa are already the right abstraction for the operations, and competing with them would reject the ready-made implementations. Selection resolves over purpose × security policy × certificate type, so a deployment can put the instance key in a TPM, user identity keys in a KMS and everything else in software simultaneously. Resolution happens at binding time, never per operation.

services.AddOpcUa()
    .AddCryptoProvider(crypto => crypto
        .For(CryptoPurpose.ApplicationInstanceKey).Use(tpm)
        .For(CryptoPurpose.UserIdentityKey).Use(keyVault));

Audit and compliance. Which module performed an operation, and whether it carries any validation, is visible through source-generated logs, opc.ua.crypto.* metrics and the address space, and can be constrained with a compliance policy. Default is Permissivezero behavioural change on upgrade.

A PKCS#11 package (OPCFoundation.NetStandard.Opc.Ua.Security.Pkcs11, optional, never referenced by Opc.Ua.Core). A certificate store over a real token addressed by RFC 7512 pkcs11: URIs, so an existing configuration moves to hardware by changing only a store path. Built on Pkcs11Interop alone rather than also taking Pkcs11Interop.X509Store: one dependency, and full mechanism control — which mattered, because RSA-PSS needs explicit CK_RSA_PKCS_PSS_PARAMS. It is the first real consumer of the detached-key seam.

A Windows CNG/TPM certificate factory, and a simulated hardware store in the test framework so the device contract is covered on every platform without hardware.

Performance

The "no performance impact" claim was unverifiable until now, because BenchmarkDotNet could not build its generated host: STACKGEN001 from a source generator in an unrelated referenced project. That is pre-existing and repo-wide — the benchmarks in Opc.Ua.Core.Encoders.Tests share the same transitive reference. Rather than change shared tooling, the fixture runs in-process, which generates no host project. (Worth its own issue.)

That made it measurable, and then worth improving. net10.0, 8 KB payload, Basic256Sha256:

Before After
Round trip (channel path) 10.239 µs / 2.70 KB 9.805 µs / 2.17 KB
EncryptAndSign 6.793 µs / 1.78 KB 6.695 µs / 1.67 KB
SignOnly 2.028 µs / 1.24 KB 1.971 µs / 1.13 KB

The encrypt side now writes the signature straight into the space already reserved for it instead of allocating and copying; the decrypt side accepts the HMAC the channel already keeps per token instead of building one per chunk. Allocations fall 6–20 %, nothing regresses.

ISymmetricCryptoProvider was deliberately not added, and the measurement is the reason: that path is ~9.8 µs of mostly AES and HMAC, and the only consumer that would justify public API there is hardware offload, which is excluded because a device round-trip per message would destroy throughput. Adding the seam with nothing implementing it would commit the hottest code in the stack to an unused interface. Recorded in docs/CryptoProvider.md.

FIPS posture

The plan records what may and may not honestly be claimed. .NET holds no CMVP certificate of its own — it calls through to the OS module — so the default provider reports FipsCapablePlatform, not "validated". A PKCS#11 token reports Uncertified unless an operator asserts a certificate, because nothing in the PKCS#11 interface reports one. net472/net48 can make no FIPS claim at all, since the BouncyCastle.Cryptography NuGet is not the validated BC-FNA product.

Not in scope

Registrable security policies (Phase 6) have ten interlocking blockers, including algorithm enums closed at compile time and policy dictionaries built by reflection over typeof(SecurityPolicies).GetFields(). Isolated into its own design issue so it does not gate this work. Its acceptance test would be lighting up ECC_curve25519/ECC_curve448, which are implemented in-tree but dead because CURVE25519 is defined in no project or props file anywhere in the repository.

Also out of scope: hot-path symmetric offload (above), HTTPS with a device-held key on Windows/macOS (SChannel and the macOS Security framework require a platform KSP; UA-TCP is unaffected everywhere), and an async asymmetric path — RSA/ECDsa are synchronous contracts, so a network-backed provider blocks a thread on the cold path.

Related Issues

Checklist

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

Tests

New: detached private keys, non-exportable key stores, channel crypto against a non-exportable key, the simulated hardware store, Windows CNG/TPM certificates, provider registry, DI wiring, auditor, key-pair generator, the Part 12 pending key store, AOT coverage, and the PKCS#11 store, URI parser and token operations.

Verified locally: 569 Core security tests on net10.0 / 561 on net48 · 260 Security.Certificates · 43 channel fixtures · 30 PKCS#11 on both TFMs (+7 correctly skipped without a token). Solution build clean on all six TFMs.

Documentation

New docs/CryptoProvider.md, cross-linked from Certificates, CertificateManager, DependencyInjection, Diagnostics, NativeAoT and README, plus a WhatsNewIn2.0 entry. MigrationGuide.md needs nothing — no existing API is obsoleted.

CI

The PKCS#11 test project joins the auto-discovered matrix on every OS. CI installs SoftHSM2 and provisions a token on Linux/macOS so the device path is genuinely exercised; the tests skip themselves when no module is present, so a missing SoftHSM2 reduces coverage rather than reddening the build.

Note on scope

This is large because the research and the implementation share a branch. If review would go better with the PKCS#11 package split into a follow-up PR, say so and I will separate it — it is self-contained and touches nothing outside its own two projects, the solution file, the package versions file and the CI workflow.

marcschier and others added 20 commits August 6, 2026 09:29
Captures the design research for making cryptographic operations replaceable
by an alternative library, an offboard service, or hardware (TPM 2.0 / HSM /
PKCS#11 / cloud KMS), without any performance cost in the default all-software
configuration, and extending to certificates so private keys need never be
materialized in process memory.

Key findings recorded in plans/cryptooffboard.md:

- .NET's RSA and ECDsa are abstract and the stack already routes every
  private-key operation through them, so roughly 85% of the stack works
  unchanged with a hardware-backed key. A bespoke asymmetric crypto interface
  would reject the ready-made RSACng (TPM), Pkcs11Interop and RSAKeyVault
  implementations.
- ECDH key agreement always uses a freshly generated ephemeral key and never
  the certificate key, so hardware is only ever asked to Sign and Decrypt.
- Hardware offload touches only cold-path operations, so the per-chunk
  symmetric path needs no change at all; a seam there would cost ~0.018%.
- The actual work is twelve certificate persistence and export call sites that
  assume a PFX round-trip always succeeds, each listed with file and line.
- Most required seams already exist: ICertificateStoreProvider,
  IPushCertificateKeyGenerator, X509SignatureGenerator, and ITokenIssuer as the
  async offboard-signing precedent.

Two decisions are flagged for maintainer sign-off: the synchronous BCL
RSA.SignHash contract versus the no-sync-over-async rule for remote KMS, and
excluding HTTPS from the hardware-key scope on Windows and macOS.

Documentation only; no code changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
Incorporates six additional requirements raised in design review, several of
which change the shape of the solution rather than refining it.

New in the plan:

- Selection and resolution model (A2, A4). Provider choice is now a resolution
  problem over three discriminators - purpose, security policy URI and
  certificate type - with a precedence chain modelled directly on the existing
  IHistorianProviderRegistry. This is what allows an instance key in a TPM,
  user identity keys in a KMS and everything else in software simultaneously.
  Resolution happens at binding time, never per operation, so the performance
  guarantee is unaffected.

- Provider capabilities and provenance (A5, A6). Every provider declares what
  it can serve and how it is validated. Selection and compliance filtering
  become two queries over one capability declaration, which unifies
  mix-and-match, missing-profile contribution, FIPS filtering and audit into a
  single mechanism instead of four bolted-on features.

- Contributing missing security policies (A3), scoped as a separate later epic.

Two research findings materially change scope:

- Registrable security policies are a far larger change than pluggable crypto.
  Ten interlocking blockers were found, including closed C# enums for every
  algorithm and policy dictionaries built by reflection over
  typeof(SecurityPolicies).GetFields(). Isolated as Phase 6 with its own design
  issue so it does not gate the hardware-key work. The acceptance test is
  lighting up ECC_curve25519/448, which are fully implemented but dead because
  the CURVE25519 symbol is defined in no project file.

- The current default configuration is not FIPS-clean. ChaCha20-Poly1305 and
  brainpool policies are advertised by default and neither is FIPS-approved;
  net472/net48 additionally use the non-validated BouncyCastle NuGet. A
  FIPS-compliant default therefore requires a compliance profile that filters
  the advertised policy set, not merely a statement about which library
  performs the maths. .NET holds no CMVP certificate of its own, so the plan
  records precisely what may and may not be claimed per platform and per TFM.

The audit requirement needs no new infrastructure: roughly thirty Report*
audit events, the Part 12 ServerConfiguration node and a deprecated-policy
LogLevel.Warning pattern already exist and are reused. The one genuine gap is
that no security or crypto metrics exist today.

Phases reorganised from six to eight. Seven new risks recorded, including the
A3/A5 tension - the profiles .NET cannot do natively are precisely the non-FIPS
ones - and three new open questions for maintainers.

Documentation only; no code changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
Adds the ability to hold a private key alongside a certificate instead of
attached to it, which is how a key resident in a TPM, an HSM, a PKCS#11 token
or a remote key service has to be represented.

Why this is needed
------------------

An empirical probe on Windows / .NET 10.0.10 disproved the assumption that a
hardware key could simply be attached with X509Certificate2.CopyWithPrivateKey:

  CopyWithPrivateKey(custom non-exportable RSA)  -> CryptographicException
  CopyWithPrivateKey(RSACng, ExportPolicy=None)  -> works
  detached key held beside the certificate       -> works

The Windows certificate pal only has fast paths for RSACng and
RSACryptoServiceProvider. For anything else it falls back to
ExportParameters(true), which a non-extractable key refuses by definition. So
while the Windows CNG/TPM path works, a PKCS#11 wrapper, an Azure Key Vault
RSA or any bespoke offboard provider cannot be bound to a certificate that way
at all. CertificateRequest.CreateSelfSigned is affected for the same reason,
since it calls CopyWithPrivateKey internally.

The detached model sidesteps the platform entirely: the wrapper holds the
certificate and the key side by side and never calls CopyWithPrivateKey, so it
behaves identically on every platform and target framework.

What changed
------------

Certificate gains CopyWithDetachedPrivateKey for RSA and ECDsa, a
HasDetachedPrivateKey property, and reports HasPrivateKey true when a detached
key is present. GetRSAPrivateKey and GetECDsaPrivateKey return the detached key
when one is set. The key participates in the existing reference counted
CertificateCore lifetime and is disposed once, with the last handle, unless the
caller opts out with ownsPrivateKey false.

Callers of GetRSAPrivateKey own the returned object and dispose it, because the
platform normally hands out a fresh handle per call. Returning the shared
detached key directly would let the first caller destroy it for everyone, so
each call returns an independent non-owning view (NonOwningRsa / NonOwningECDsa)
that forwards every operation and ignores disposal.

Test support
------------

NonExportableRsa and NonExportableECDsa are added to Opc.Ua.Core.TestFramework.
They perform every operation correctly but refuse to surrender private key
material, mirroring a CNG key created with CngExportPolicies.None, and expose a
PrivateKeyExportAttempts counter so a test can assert that a code path never
reaches for private material rather than merely tolerating the failure. Only
ExportParameters needs to reject the request; the base class funnels the
ExportRSAPrivateKey, ExportPkcs8PrivateKey and encrypted variants through it.

Opc.Ua.Security.Certificates.Tests now references Opc.Ua.Core.TestFramework,
which its own project comment already described as shared with that suite.

Verified: 262 tests pass on net10.0 and net48 with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
The inner X509Certificate2 of a detached key certificate carries no private key, so a PKCS#12 export would succeed and quietly produce a file without one. That is worse than failing: a caller asking for the key would be handed a useless blob and only discover it later.

Export now throws for PKCS#12 when the key is detached, while exporting the public certificate keeps working.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
The stack repeatedly took a PKCS#12 round-trip for granted, so a certificate
whose key is not extractable failed in several places that had nothing to do
with the key itself. Such keys are the norm once a key lives in a TPM, an HSM,
a PKCS#11 token or a remote key service, and they already occur today with a
CNG key created with CngExportPolicies.None. This is also the root of the class
of bug reported in #2637.

Nine call sites are addressed:

DefaultCertificateFactory.DetachFromSourceKey and X509Utils.CreateCopyWithPrivateKey
both exist to escape an ephemeral key handle owned by a caller that is about to
dispose it. A non-extractable key is by definition not such a handle, so there
is nothing to detach and the certificate is returned as-is.

DirectoryCertificateStore.AddAsync now stores the public certificate and warns,
rather than failing. A key that cannot be exported was never going to reach the
disk, and it remains reachable through the store it actually lives in.

X509CertificateStore.AddAsync on Windows re-imports the key with PersistKeySet
so the platform store can persist it. When the key is not extractable it is
already held in a key storage provider the store can reach, which is exactly
what the re-import was trying to achieve, so the original certificate is added.

PEMWriter reported the raw platform error, which says nothing useful. Private
key export now fails with NotSupportedException stating the actual constraint.

ConfigurationNodeManager carried the previous private key over to a new
certificate during the Part 12 UpdateCertificate flow with no error handling at
all. It now reports BadSecurityChecksFailed and points at CreateSigningRequest
with RegeneratePrivateKey, which is the flow that works for keys that cannot be
copied.

SharedKeyValuePendingCertificateKeyStore stages a key for another replica to
collect. A key that cannot leave its device cannot be staged, so the store now
reports that it cannot hold it instead of throwing.

Verified: 500 tests in the Core security namespace pass on net10.0 and 492 on
net48, plus 263 in Opc.Ua.Security.Certificates.Tests, with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
Exercises the operations the secure channel performs during OpenSecureChannel and ActivateSession using a private key that can never be exported: the asymmetric signature over the handshake, verification of the peer signature, the RSA unwrapping of the peer secret, and the key size helpers the channel uses to size its buffers.

Each test asserts PrivateKeyExportAttempts is zero, so a path that merely tolerated an export failure would still fail the test. Covers Basic256Sha256, Aes128_Sha256_RsaOaep, ECC_nistP256 and ECC_nistP384.

This is the substance of the claim that the stack works unchanged with hardware held keys: if these pass, the key never has to leave its device for a channel to be established.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
The detached key model added earlier is what every provider outside the
platform key storage providers has to use: PKCS#11 tokens, cloud key services
and anything else that cannot satisfy X509Certificate2.CopyWithPrivateKey.
Until now it had unit coverage but nothing exercised it through an actual
certificate store.

The Windows CNG and TPM store cannot fill that gap. It is Windows only, and it
is the one path that already worked before the detached key change, because
RSACng hits the CopyWithPrivateKey fast path. A store that reproduces the token
contract in memory covers the interesting path on every platform instead.

SimulatedHardwareCertificateStore holds certificates whose private keys can be
used but never extracted, generates key pairs on request, and refuses to import
key material it did not generate, recording the attempt so a test can assert
the stack never depends on writing a key back. Its provider hands out handles
that share one backing token per store path, since the stack opens and disposes
stores freely and a real token is not torn down because one consumer closed its
session. Registration needs no new plumbing: CertificateManagerOptions
.AddStoreProvider already accepts an ICertificateStoreProvider.

The tests drive the full path a channel would: load the certificate from the
store, sign the handshake, verify it, and unwrap a peer secret, for
Basic256Sha256, Aes256_Sha256_RsaPss and ECC_nistP256.

#nullable enable is added to the store file because ICertificateStore is
annotated and the test framework project does not enable nullable globally.

Verified: 15 non-exportable key tests pass on net48, and 514 tests in the Core
security namespace pass on net10.0, up from 500, with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
Covers the one hardware path that needs no third party dependency. Keys are
created in a Windows key storage provider with CngExportPolicies.None, so the
private key is genuinely non extractable, and the resulting certificate is
driven through the same channel crypto the secure channel performs.

The Platform Crypto Provider is used when a TPM is present, and the software
key storage provider stands in otherwise. The fallback still produces a non
extractable key, so CI agents without a TPM exercise the same code paths; a
test reports which of the two was available so a failure on a TPM equipped
machine can be told apart from one without.

Two portability details. CngProvider.MicrosoftPlatformCryptoProvider only
exists on .NET Core and later, so the provider is named explicitly to keep the
factory working on .NET Framework. SupportedOSPlatformAttribute is internal on
net48, where it comes from the repo polyfill, so the annotation is gated to
.NET 5 and later, which is also the only place the platform compatibility
analyzer runs.

Note that this path attaches the key with X509Certificate2.CopyWithPrivateKey,
which succeeds only because RSACng is one of the two implementations the
Windows certificate layer recognises. It is therefore not representative of
providers that are not CNG backed; those must use the detached key model, which
the simulated hardware store covers on every platform.

Verified: 19 non-exportable key tests pass on net10.0 and net48, and 518 tests
in the Core security namespace pass, up from 514, with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
CertificateStoreIdentifier.DetermineStoreType could only see the built-in store
types and the legacy static registry, which is marked obsolete. A store type
registered through CertificateManagerOptions.AddStoreProvider was invisible to
it, so a configuration that relied on auto-detection silently fell back to
Directory and opened the wrong kind of store.

An overload taking the providers is added and used by CertificateManager, which
already holds them and is where store paths are resolved at runtime. The
remaining call sites are in the fluent configuration builder, which has no
provider access; configuration that goes through those paths must continue to
state the store type explicitly, and the overload documents that.

This makes OpenStore probe each provider by path before falling back, which is
a new call on the provider contract. One existing test used a strict mock that
did not anticipate SupportsStorePath being called; it now stubs it to false, so
the provider is still selected by its store type name and the test keeps its
original meaning.

Verified: 519 tests in the Core security namespace pass on net10.0 and 511 on
net48, with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
Introduces the model that lets a deployment put its application instance key in
a TPM, have user identity tokens signed by a remote key service, narrow
certificate issuance to one security policy, and leave everything else to the
platform, all at once.

A provider does not perform the operations. RSA and ECDsa are already abstract
and hardware and cloud implementations of them already exist; a competing
signing interface would make those unusable. What a provider supplies instead
is the part the platform does not model: which capabilities it can serve, and
what may be said about the module behind it. Those two facts drive selection,
the advertised security policy set, and the audit trail.

CryptoPurpose is a readonly record struct with well known instances rather than
an enum. The security constants in this stack are already closed enums, which
is the single largest obstacle to contributing a new algorithm; repeating that
here would make the provider model equally closed.

CryptoValidationStatus records provenance. The levels distinguish a provider
that can name a validation certificate, one that merely defers to whatever the
platform was configured with, and one that carries no validation at all. The
default provider reports the middle case, because whether the underlying module
runs in a validated mode is a property of the machine and not something this
stack can assert. Unknown is treated as uncertified when filtering.

CryptoProviderRegistry resolves from the most specific registration to the
least: purpose and policy, then purpose, then the registered default, then the
platform. A provider bound to a purpose it never claimed is skipped rather than
used, so a configuration mistake fails near its cause instead of deep in a
handshake. Resolution is intended for the point where something is bound, not
per operation.

Verified: 10 new tests covering the precedence matrix, plus 529 tests in the
Core security namespace on net10.0 and 521 on net48, with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
AddCryptoProvider registers the registry and, in the overload that takes a
configuration action, binds providers to purposes and security policies:

    services.AddOpcUa()
        .AddCryptoProvider(crypto => crypto
            .For(CryptoPurpose.ApplicationInstanceKey).Use(tpmProvider)
            .For(CryptoPurpose.KeyAgreement).Use(tpmProvider)
            .For(CryptoPurpose.UserIdentityKey).Use(keyVaultProvider));

Bindings are stated explicitly rather than discovered. Scanning assemblies for
providers would be convenient but is incompatible with the trimming and ahead
of time posture of this stack, and it would make the effective security
configuration depend on what happens to be loaded.

Configurations are carried through the container as CryptoProviderConfiguration
instances and applied to the resolved registry, so several independent calls
compose instead of overwriting one another. That lets a library contribute a
binding without knowing what the host already did. The registry itself is
registered with TryAddSingleton, so a consumer that supplied its own keeps it.

ChannelQuotas gains a CryptoProviders property. It already carries
ICertificateValidatorEx, so a service-like reference there is established
practice, and it reaches every channel without touching four constructors. The
channel is expected to resolve once when it opens and hold the result, in the
same way it caches the security policy on its token; nothing on a per message
path consults it.

Registering the model changes no behaviour: with nothing bound the registry
resolves to platform cryptography.

Verified: 15 crypto provider tests, plus 534 tests in the Core security
namespace on net10.0 and 526 on net48, with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
CryptoProviderAuditor reports which providers are in use and which of them carry no validation, through the surfaces the stack already has: source generated log messages under a new CoreEventIds.CryptoProvider block, and opc.ua.crypto.* metrics, of which there were none before.

CryptoCompliancePolicy decides how strictly this is enforced. Permissive is the default and leaves an existing deployment exactly as it was: nothing is warned about and nothing is refused. WarnOnUncertified reports every provider that carries no validation. FipsOnly refuses to start, because a deployment that asked for validated cryptography and did not get it should not run and quietly use something else.

The metrics are published regardless of policy. They are pull based and cost nothing when nobody reads them, so the information stays available without changing behaviour. A provider that declines to state its validation is treated as uncertified, so silence is not a way to pass an audit.

Verified: 22 crypto provider tests, plus 541 tests in the Core security namespace on net10.0 and 533 on net48, with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
IKeyPairGenerator decides how a new application instance certificate gets its key. The builder arrives with the subject, subject alternative names and lifetime already set, so an implementation only chooses where the key comes from and how the certificate is signed. DefaultKeyPairGenerator reproduces the previous behaviour exactly, and ApplicationInstance.KeyPairGenerator defaults to it, so nothing changes unless a host sets it.

This was the one remaining place that hard coded software key generation. The GDS push path already had IPushCertificateKeyGenerator; startup did not.

The interface documents two constraints a hardware implementation has to respect, both found while building the earlier detached key support: the parameterless CreateForRSA and CreateForECDsa generate a key in software and cannot be used, and CertificateRequest.CreateSelfSigned cannot be used either because it attaches the key with X509Certificate2.CopyWithPrivateKey, which fails for a non extractable key. Such an implementation must supply the public key it generated in the device and sign with an X509SignatureGenerator that calls back into it.

Verified: 28 crypto provider tests, 221 configuration tests, and 547 tests in the Core security namespace on net10.0 with 539 on net48, all with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
CreateSigningRequest with RegeneratePrivateKey regenerates a key that UpdateCertificate consumes later, possibly in another Session or after a restart. The directory backed store does this by exporting the key to a PKCS#12 file, which a key held in a TPM, an HSM or a PKCS#11 token refuses, so that store declines and the request fails with BadNotSupported. This was the single blocker for hardware backed GDS push.

For a device held key there is nothing to export and nothing to protect: the device already is the durable store. HardwarePendingCertificateKeyStore keeps only the association between the pending certificate and its scope, writing the public certificate into the group's own store, and re-attaches the key on the way back by asking that store to load it. A software key is declined so the caller falls back to a store that knows how to protect exportable material.

The store can be given a certificate store provider directly. CertificateStoreIdentifier.OpenStore resolves store types through the built-in set and the obsolete static registry, so it cannot see a provider registered through dependency injection; supplying it avoids that path.

Also fixes the simulated token: AddAsync was replacing a key bearing entry with the public certificate someone handed it, discarding its own key. A real token does not do that.

Verified: 4 new tests, plus 547 tests in the Core security namespace with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
CryptoCompliance decides which security policies a compliance posture permits. Several of the policies the stack supports use algorithms that are not approved for validated cryptography, and they are enabled by default because withholding them would break deployments that use them today. FipsOnly withholds them: the two SHA-1 based policies, every ChaCha20-Poly1305 variant, the brainpool curves and curve25519/448. An unknown policy is permitted, because this filter is not the arbiter of which policies exist.

The AOT tests exercise registration, resolution, the auditor and the compliance filter in a trimmed ahead of time compiled binary. That is what catches a lookup that only works because metadata happened to survive.

docs/CryptoProvider.md documents the whole model and is linked from docs/README.md. It states plainly what can and cannot be claimed about FIPS: .NET holds no validation certificate of its own, so the honest claim is that with FipsOnly the stack performs no cryptography outside the platform modules, and those are validated when the operating system is configured for it. net472 and net48 cannot make the claim at all while BouncyCastle is in the certificate path. The known limitations are listed rather than left to be discovered.

Verified: 46 crypto provider tests, 4 AOT tests, and 565 tests in the Core security namespace on net10.0 with 557 on net48, all with no regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
Every other benchmark in the suite measures a full round trip, where the network dominates and a change of a few hundred nanoseconds is invisible. This one measures the symmetric encrypt and sign work on its own, which is what makes a claim about the cost of the per message path checkable.

The methods double as NUnit tests so the code stays correct and compiled. BenchmarkDotNet cannot currently build the host from this assembly: STACKGEN001 'Stack generation not supported for Opc.Ua.Gds.Common assembly', which comes from a source generator in an unrelated referenced project and is not caused by this work. That is recorded on the fixture rather than left to be rediscovered.

Verified: 49 crypto provider tests pass on net48 and the benchmark methods pass as tests on net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
The per-message symmetric path allocated more than it needed to. On the
encrypt side the signature was computed into a fresh array and then copied
into the space already reserved for it. On the decrypt side a whole HMAC was
built per chunk, even though the channel already keeps one per token and
hands it to the encrypt side.

Both are now avoided. The signature is written straight into its destination,
and the decrypt side takes an optional HMAC so the channel can pass the one it
already has. Callers that pass nothing get the previous behaviour, so nothing
outside the channel has to change.

The benchmark could not run at all before this: BenchmarkDotNet generates a
host project, and building it fails with STACKGEN001 from a source generator
in an unrelated referenced project. That is pre-existing and repo-wide - the
benchmarks in Opc.Ua.Core.Encoders.Tests have the same transitive reference.
Rather than change shared tooling, the fixture now runs in process, which
generates no host project. It also gained a variant that passes the shared
HMAC, so what the channel actually does is what gets measured.

Measured on net10.0, 8 KB payload, Basic256Sha256:

  round trip (channel path)  10.239 us / 2.70 KB -> 9.805 us / 2.17 KB
  EncryptAndSign              6.793 us / 1.78 KB -> 6.695 us / 1.67 KB
  SignOnly                    2.028 us / 1.24 KB -> 1.971 us / 1.13 KB

Allocations fall by 6 to 20 percent and no timing regresses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
Everything the crypto provider work added so far was exercised against a
simulated store. This is the first provider backed by a real device: an
optional Opc.Ua.Security.Pkcs11 package with a certificate store over a
hardware token, smart card or HSM, addressed by an RFC 7512 pkcs11: URI.
The private key is used for signing and decryption and never enters this
process.

The store binds the token key with CopyWithDetachedPrivateKey rather than
X509Certificate2.CopyWithPrivateKey. That is not a preference. On Windows the
certificate layer has fast paths only for RSACng and RSACryptoServiceProvider
and otherwise falls back to exporting the private parameters, so the obvious
approach throws for any token backed key. The detached form works on every
platform, and this package is its first real consumer.

Built directly on Pkcs11Interop rather than Pkcs11Interop.X509Store: one
dependency instead of two, and full control over mechanism parameters, which
RSA-PSS needs. CKM_RSA_PKCS_PSS with explicit CK_RSA_PKCS_PSS_PARAMS is
supported, so Aes256_Sha256_RsaPss can be served from a token where the device
implements it. PKCS#1 v1.5 signing supplies the DigestInfo the mechanism
expects, and OAEP decryption passes the matching MGF1.

The package is never referenced by Opc.Ua.Core, so applications that do not
use a token are unaffected, including their Native AOT support - this package
is deliberately not AOT-validated, because Pkcs11Interop resolves the module
through native interop and carries no annotations.

The token reports its validation status as uncertified unless an operator
asserts one. A token may well hold a FIPS certificate, but nothing in the
PKCS#11 interface reports one, so the stack must not assume it. That is
exactly what the audit surfaces exist to record.

CI installs SoftHSM2 and provisions a token so the device path is genuinely
covered. Tests skip themselves when no module is present, so a missing
SoftHSM2 reduces coverage rather than breaking the build.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
CryptoProvider.md was added on its own without the cross-links the repo
expects. Certificates, CertificateManager, DependencyInjection, Diagnostics
and NativeAoT each now point at it from the place a reader would look, and
WhatsNewIn2.0 gains an entry for the new public API.

CryptoProvider.md itself documents the PKCS#11 store. MigrationGuide.md needs
nothing: none of this obsoletes existing API.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
The plan left this to be decided with data rather than up front. The isolated
benchmark puts a full 8 KB round trip at roughly 9.8 us and 2.2 KB, nearly all
of it inside AES and HMAC, and the one consumer that would justify a public
interface there - hardware offload - is already excluded because a device
round-trip per message would destroy throughput.

So the seam is deliberately not added, and the reason is written down next to
the other limitations rather than left as an open question.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
@marcschier marcschier changed the title Add research plan for pluggable crypto and hardware-held private keys (#4190) Pluggable cryptography and hardware-held private keys (#4190) Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Code coverage

Coverage gate failed. This check is advisory and does not block the merge.

Check Result Threshold
✅ Project line rate 86.43% (207006/239496 lines) >= 70.00%
✅ Project branch rate 76.20% >= 60.00%
❌ Patch coverage 73.62% (773/1050 changed lines) >= 75.00% (> 100 changed lines)
ℹ️ Baseline delta (advisory) +12.83 pp 73.60% recorded
  • ❌ Patch coverage 73.62% is below 75.00% for > 100 changed lines (277 of 1050 changed lines are uncovered).
Uncovered changed lines
  • src/Opc.Ua.Server/Configuration/HardwarePendingCertificateKeyStore.cs: 64, 96, 101, 112, 137, 153, 177, 197
  • src/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs: 2329, 2339, 2341, 2342, 2343, 2344, 2345, 2346, 2347
  • src/Opc.Ua.Redundancy.Server/Security/SharedKeyValuePendingCertificateKeyStore.cs: 123, 130
  • src/Opc.Ua.Core/Security/Crypto/CryptoCompliance.cs: 68, 86
  • src/Opc.Ua.Security.Certificates/X509Certificate/NonOwningRsa.cs: 63, 66, 77, 78, 124
  • src/Opc.Ua.Core/Security/Certificates/CryptoUtils.cs: 735, 1173, 1174, 1175, 1187, 1188
  • src/Opc.Ua.Core/Security/Crypto/CryptoCapability.cs: 89
  • src/Opc.Ua.Security.Pkcs11/Pkcs11ECDsa.cs: 61, 62, 63, 64, 66, 67, 69, 70, 72, 73, 74, 77, 80, 89, 91, 92, 95, 104, 106, 107, 110, 119, 120, 129, 130, 139, 141, 144, 145, 146, 151, 161, 167, 173, 175, 178, 179
  • src/Opc.Ua.Core/Security/Crypto/CryptoProviderBuilder.cs: 69, 70, 91, 124, 133, 134, 135, 141, 147, 155, 163
  • src/Opc.Ua.Security.Pkcs11/Pkcs11Rsa.cs: 89, 92, 116, 117, 134, 139, 169, 170, 200, 205, 231, 232, 242, 248
  • src/Opc.Ua.Core/Security/Crypto/CryptoValidationStatus.cs: 115, 117, 120
  • src/Opc.Ua.Core/Security/Crypto/CryptoPurpose.cs: 112
  • src/Opc.Ua.Security.Pkcs11/OpcUaPkcs11BuilderExtensions.cs: 75, 77, 80, 81, 83, 116, 118, 121, 122, 123, 124, 126
  • src/Opc.Ua.Security.Certificates/X509Certificate/NonOwningECDsa.cs: 57, 60, 65, 71, 77, 78, 83, 84, 95, 111
  • src/Opc.Ua.Security.Certificates/X509Certificate/DetachedKeyHash.cs: 63, 64, 65, 79, 81, 84, 91, 95, 96
  • src/Opc.Ua.Security.Pkcs11/Pkcs11CertificateStore.cs: 182, 201, 221, 242, 244, 246, 249, 251, 255, 256, 257, 258, 261, 263, 266, 267, 270, 285, 294, 301, 310, 316, 325, 356, 363, 390, 392, 394, 396, 398, 399, 400, 401, 405, 419, 420, 421
  • src/Opc.Ua.Security.Pkcs11/Pkcs11TokenOptions.cs: 294, 321, 322
  • src/Opc.Ua.Security.Pkcs11/Pkcs11Token.cs: 77, 78, 79, 80, 104, 106, 107, 108, 181, 196, 199, 201, 202, 203, 205, 207, 345, 357, 361, 372, 373, 374, 379, 380, 381, 419, 420, 421, 422, 429
  • src/Opc.Ua.Security.Certificates/CertificateManager/DefaultCertificateFactory.cs: 337, 344
  • src/Opc.Ua.Core/Security/Certificates/X509Utils.cs: 730, 736
  • src/Opc.Ua.Core/Security/Crypto/CryptoProviderAuditor.cs: 79, 206, 207, 209, 212, 213, 215, 216, 217, 218, 219, 222, 227
  • src/Opc.Ua.Security.Certificates/PEM/PEMWriter.cs: 103, 124, 142, 143, 144, 145, 146, 194
  • src/Opc.Ua.Core/Security/Crypto/OpcUaCryptoBuilderExtensions.cs: 55, 91, 96, 143
  • src/Opc.Ua.Core/Security/Certificates/X509CertificateStore/X509CertificateStore.cs: 191, 192, 193, 194, 195, 196, 197, 198, 205, 206
  • src/Opc.Ua.Configuration/ApplicationInstance.cs: 155
  • src/Opc.Ua.Security.Pkcs11/Pkcs11Digest.cs: 63, 65, 68, 70, 73, 74, 93, 95, 98, 100, 103, 104, 123, 124, 125, 150, 151, 152, 162, 163, 164, 168, 170, 173, 175, 178, 180, 183, 184, 197, 199, 200, 203, 205, 206, 209, 210

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

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

@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 65.52381% with 362 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.49%. Comparing base (ca89ac8) to head (eda2afd).

Files with missing lines Patch % Lines
...c/Opc.Ua.Security.Pkcs11/Pkcs11CertificateStore.cs 57.98% 37 Missing and 13 partials ⚠️
src/Opc.Ua.Security.Pkcs11/Pkcs11Token.cs 70.13% 30 Missing and 13 partials ⚠️
src/Opc.Ua.Security.Pkcs11/Pkcs11Digest.cs 36.92% 37 Missing and 4 partials ⚠️
src/Opc.Ua.Security.Pkcs11/Pkcs11ECDsa.cs 0.00% 37 Missing ⚠️
src/Opc.Ua.Security.Pkcs11/Pkcs11Rsa.cs 67.60% 14 Missing and 9 partials ⚠️
...onfiguration/HardwarePendingCertificateKeyStore.cs 67.27% 8 Missing and 10 partials ⚠️
...c.Ua.Core/Security/Crypto/CryptoProviderAuditor.cs 73.33% 13 Missing and 3 partials ⚠️
...c.Ua.Core/Security/Crypto/CryptoProviderBuilder.cs 38.09% 11 Missing and 2 partials ⚠️
...Ua.Security.Pkcs11/OpcUaPkcs11BuilderExtensions.cs 0.00% 12 Missing ⚠️
...ity.Certificates/X509Certificate/NonOwningECDsa.cs 42.10% 10 Missing and 1 partial ⚠️
... and 21 more

❌ Your patch check has failed because the patch coverage (65.52%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #4192      +/-   ##
==========================================
- Coverage   80.88%   80.49%   -0.40%     
==========================================
  Files        1719     1742      +23     
  Lines      238505   239496     +991     
  Branches    41276    41470     +194     
==========================================
- Hits       192926   192771     -155     
- Misses      31214    32345    +1131     
- Partials    14365    14380      +15     
Flag Coverage Δ
actions 80.49% <65.52%> (-0.40%) ⬇️

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

Files with missing lines Coverage Δ
...ecurity/Certificates/CertificateStoreIdentifier.cs 79.12% <100.00%> (-0.88%) ⬇️
...Security/Certificates/DirectoryCertificateStore.cs 79.82% <100.00%> (+0.17%) ⬆️
....Ua.Core/Security/Crypto/PlatformCryptoProvider.cs 100.00% <100.00%> (ø)
src/Opc.Ua.Core/Stack/Tcp/ChannelQuotas.cs 100.00% <ø> (ø)
...c.Ua.Core/Stack/Tcp/UaSCBinaryChannel.Symmetric.cs 79.77% <100.00%> (+0.05%) ⬆️
src/Opc.Ua.Security.Pkcs11/Pkcs11StoreProvider.cs 100.00% <100.00%> (ø)
...tificates/CertificateManager/CertificateManager.cs 77.44% <0.00%> (ø)
src/Opc.Ua.Core/Security/Crypto/CryptoPurpose.cs 90.00% <90.00%> (ø)
...Ua.Core/Security/Crypto/DefaultKeyPairGenerator.cs 94.11% <94.11%> (ø)
src/Opc.Ua.Security.Pkcs11/Pkcs11CryptoProvider.cs 92.30% <92.30%> (ø)
... and 27 more

... and 50 files with indirect coverage changes

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

marcschier and others added 2 commits August 7, 2026 13:10
CI failed on the Core and Server suites with the certificate leak detector,
not with a failing test: all 4212 Core and 4028 Server tests passed, then
global teardown found 8 and 3 certificates created but never disposed.

CertificateCollection.Add calls AddRef, so it takes its own handle and the
caller still owns theirs. Three places built a certificate purely to hand it to
Add and then dropped it, which leaks the original every time. The simulated
hardware store did it in Enumerate and FindByThumbprint, and the PKCS#11 store
had inherited the same shape - that one is a real leak in shipping code, not
just in tests.

Two more of the same kind: the Windows CNG factory dropped the intermediate
public-only certificate that CopyWithDetachedPrivateKey copies from, and the
simulated hardware store provider cached a token per path without ever
disposing it, so every certificate generated in a test stayed alive. The
provider owns those tokens, so it is now IDisposable and the fixtures dispose
it.

The Windows CNG leak would not have shown up in the failing Linux jobs at all -
those tests skip off Windows - so it was on course to fail the Windows legs
next.

Verified: the leak detector reports nothing for any of the affected fixtures,
and the solution still builds with 0 warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
… properly

The Security.Pkcs11 job failed for a reason I had built in myself. The skip
contract keyed off "is a module installed", not "is the token usable", so on CI
- where SoftHSM2 was present but provisioning had silently failed - the token
tests ran anyway and asserted. Partial provisioning is the more likely state on
a developer machine, so this was the wrong way round.

It is now inverted on both sides. The tests skip whenever the token holds no
certificate, so a half-configured machine loses coverage instead of breaking
the build. CI fails loudly if provisioning does not produce what it should, so
the coverage cannot quietly disappear.

Provisioning itself was the original failure: openssl req -engine pkcs11 does
not work on ubuntu-latest, because OpenSSL 3.x has moved to providers and the
legacy engine is not there. The step now builds the key and certificate in
software and imports both with pkcs11-tool, then asserts both objects are on
the token. Importing is equivalent for what these tests check: SoftHSM stores
an imported private key with CKA_SENSITIVE=true and CKA_EXTRACTABLE=false, so
it is non-extractable once it is on the token.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
/// </remarks>
private static readonly HashSet<string> s_notApproved = new(StringComparer.Ordinal)
{
SecurityPolicies.Basic128Rsa15,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

better add this compliance as a property on SecurityPolicy Info for better maintainability

Comment thread plans/cryptooffboard.md
@@ -0,0 +1,1140 @@
# Crypto offboarding — pluggable cryptography and hardware-held private keys

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Check what is not fully accomplished yet and remove the rest, making this a roadmap doc for crypto plugin work. Unless it was discarded and deferred forever. If nothing is remaining remove this doc

Message = "The private key of certificate {Thumbprint} is not exportable and was " +
"not written to the store. Only the public certificate was stored; the key " +
"remains where it resides.")]
public static partial void DirectoryStoreLog24(this ILogger logger, string? thumbprint);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Better name for the log method


// CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived,
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
#pragma warning disable CA2000

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fix correctly, or disable with clear reasoning where needed. Do for all places where per file disable CA2000 and related CAxxx.

<PackageReadmeFile>NugetREADME.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Nullable>enable</Nullable>
<!--

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Try to make it so even if the dependency is not explicitly marked and validate nothing breaks via the AoT tests. If not supported, keep it as is for now. Considering upstream contribution then for native aot support (open an issue to track it).

@marcschier
marcschier marked this pull request as ready for review August 7, 2026 11:57
Copilot AI review requested due to automatic review settings August 7, 2026 11:57
With provisioning fixed, the token tests finally reached the module and failed
with DllNotFoundException for libdl. Pkcs11Interop loads the PKCS#11 module
through DllImport("libdl"), and glibc 2.34 folded libdl into libc; Ubuntu now
ships only the libdl.so.2 ABI stub, which the .NET loader will not bind the
bare name "libdl" to.

CI creates the missing name. This is not a CI quirk - any consumer on a current
distribution hits it - so it is documented as a prerequisite in both the package
readme and the crypto provider guide, along with the one-line fix.

The skip rule is also sharpened. A module that was named explicitly through
OPCUA_PKCS11_MODULE and then cannot be loaded is a real failure and is allowed
to fail, which is the CI case. A module merely found by probing well known paths
only costs coverage, which is the developer machine case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a pluggable cryptography “provider model” across the stack to support hardware/offboard/private-key-handle scenarios (TPM/HSM/PKCS#11/KMS) and adds “detached private key” handling so private keys can remain outside process memory. It also adds an optional PKCS#11 package + CI provisioning (SoftHSM2) and extends tests/docs to cover non-exportable keys and provider selection/auditing.

Changes:

  • Add detached-private-key support and harden key export paths so non-extractable keys fail loudly or safely degrade to public-cert-only storage.
  • Introduce crypto provider registry/builder/auditing/compliance primitives + DI registration and wiring.
  • Add optional Opc.Ua.Security.Pkcs11 package, test project, and CI SoftHSM2 provisioning to exercise real PKCS#11 module behavior.

Reviewed changes

Copilot reviewed 82 out of 83 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
UA.slnx Adds PKCS#11 library + tests to the solution.
Directory.Packages.props Central package version for Pkcs11Interop.
.github/workflows/buildandtest.yml Provisions SoftHSM2 for PKCS#11 tests on Linux/macOS.
src/Opc.Ua.Core/Security/Crypto/ICryptoProvider.cs Defines provider metadata surface (capabilities + validation provenance).
src/Opc.Ua.Core/Security/Crypto/ICryptoProviderRegistry.cs Defines provider registry contract and resolution rules.
src/Opc.Ua.Core/Security/Crypto/CryptoProviderRegistry.cs Implements provider registration/resolution (note: auditing completeness issue flagged).
src/Opc.Ua.Core/Security/Crypto/CryptoProviderBuilder.cs Fluent binding API for purposes/policies → providers.
src/Opc.Ua.Core/Security/Crypto/OpcUaCryptoBuilderExtensions.cs DI registration + configuration composition hook.
src/Opc.Ua.Core/Security/Crypto/PlatformCryptoProvider.cs Default provider describing platform cryptography.
src/Opc.Ua.Core/Security/Crypto/CryptoPurpose.cs Purpose value type for provider selection.
src/Opc.Ua.Core/Security/Crypto/CryptoCapability.cs Capability matching primitives for provider selection/advertisement.
src/Opc.Ua.Core/Security/Crypto/CryptoValidationStatus.cs Validation provenance model (uncertified/platform/validated).
src/Opc.Ua.Core/Security/Crypto/CryptoCompliance.cs Filters security policies under compliance posture.
src/Opc.Ua.Core/Security/Crypto/CryptoCompliancePolicy.cs Defines permissive/warn/FIPS-only modes.
src/Opc.Ua.Core/Stack/Tcp/ChannelQuotas.cs Adds channel-level registry hook for provider resolution.
src/Opc.Ua.Core/Stack/Tcp/UaSCBinaryChannel.Symmetric.cs Reuses cached HMAC per token for performance.
src/Opc.Ua.Core/Security/Certificates/CryptoUtils.cs Performance improvements + new optional HMAC reuse parameter (binary-compat concern flagged).
src/Opc.Ua.Core/Security/Certificates/DirectoryCertificateStore.cs Stores public-only cert when private key is non-exportable + adds warning log.
src/Opc.Ua.Core/Security/Certificates/X509CertificateStore/X509CertificateStore.cs Handles non-exportable key paths on Windows store add.
src/Opc.Ua.Core/Security/Certificates/X509Utils.cs Avoids PKCS#12 detach path when key is non-extractable.
src/Opc.Ua.Core/Security/Certificates/CertificateStoreIdentifier.cs Adds provider-aware store-type detection overload.
src/Opc.Ua.Core/Security/Certificates/CertificateManager/CertificateManager.cs Uses provider-aware store-type detection.
src/Opc.Ua.Configuration/ApplicationInstance.cs Adds KeyPairGenerator seam and routes certificate creation through it.
src/Opc.Ua.Server/Configuration/HardwarePendingCertificateKeyStore.cs Adds staging for hardware-held pending keys (durability concern flagged).
src/Opc.Ua.Server/Configuration/ConfigurationNodeManager.cs Improves error for carry-over of non-extractable key scenario.
src/Opc.Ua.Redundancy.Server/Security/SharedKeyValuePendingCertificateKeyStore.cs Returns false when non-extractable key can’t be staged via PKCS#12.
src/Opc.Ua.Security.Certificates/X509Certificate/NonOwningRsa.cs Non-owning RSA wrapper for detached-key view semantics.
src/Opc.Ua.Security.Certificates/X509Certificate/NonOwningECDsa.cs Non-owning ECDsa wrapper for detached-key view semantics.
src/Opc.Ua.Security.Certificates/X509Certificate/DetachedKeyHash.cs Hash helper for detached-key views across TFMs.
src/Opc.Ua.Security.Certificates/CertificateManager/DefaultCertificateFactory.cs Detach/export hardening for non-extractable scenarios.
src/Opc.Ua.Security.Certificates/PEM/PEMWriter.cs Clearer exception for non-exportable private key PEM export.
src/Opc.Ua.Security.Pkcs11/Opc.Ua.Security.Pkcs11.csproj New optional PKCS#11 library project.
src/Opc.Ua.Security.Pkcs11/Pkcs11StoreProvider.cs Store provider for RFC 7512 pkcs11: URIs.
src/Opc.Ua.Security.Pkcs11/Pkcs11CryptoProvider.cs Provider metadata for PKCS#11-backed purposes/validation.
src/Opc.Ua.Security.Pkcs11/Pkcs11ECDsa.cs ECDsa implementation backed by PKCS#11 token signing.
src/Opc.Ua.Security.Pkcs11/OpcUaPkcs11BuilderExtensions.cs DI registration helpers for PKCS#11 store/provider.
src/Opc.Ua.Security.Pkcs11/NugetREADME.md Package usage + limitations + AOT note.
src/Opc.Ua.Security.Pkcs11/EventIds.cs Assembly-specific event-id offsets for source-gen logs.
src/Opc.Ua.Security.Pkcs11/Properties/AssemblyInfo.cs CLS compliance attribute for PKCS#11 assembly.
tests/Opc.Ua.Core.Tests/Security/Crypto/* Tests for generator seam, DI bindings, auditing, compliance.
tests/Opc.Ua.Core.Tests/Security/Certificates/* Tests for non-exportable key stores and channel crypto paths.
tests/Opc.Ua.Core.TestFramework/* Test doubles + Windows CNG/TPM factory helpers.
tests/Opc.Ua.Server.Tests/Configuration/HardwarePendingCertificateKeyStoreTests.cs Tests the hardware pending-key store flow.
tests/Opc.Ua.Security.Pkcs11.Tests/* PKCS#11 URI parsing, provider/store behavior, and token-backed tests (sync-over-async concern flagged).
tests/Opc.Ua.Security.Pkcs11.Tests/Opc.Ua.Security.Pkcs11.Tests.csproj New PKCS#11 test project wiring.
tests/Opc.Ua.Security.Pkcs11.Tests/AssemblyInfo.cs CLS compliance attribute for test assembly.
tests/Opc.Ua.Security.Certificates.Tests/Opc.Ua.Security.Certificates.Tests.csproj Adds missing TestFramework reference.
tests/Opc.Ua.Aot.Tests/CryptoProviderAotTests.cs AOT coverage for provider resolution/auditor/compliance filtering.
docs/README.md Links new CryptoProvider documentation.
docs/Certificates.md Notes hardware/offboard keys + PKCS#11 package pointer.
docs/CertificateManager.md Documents PKCS#11 store provider.
docs/DependencyInjection.md Lists new DI entrypoints for crypto + PKCS#11 store.
docs/Diagnostics.md Notes opc.ua.crypto.* metrics/audit events.
docs/NativeAoT.md Notes provider model AOT support + PKCS#11 exception.
docs/WhatsNewIn2.0.md Adds “pluggable cryptography” entry for 2.0 notes.

Comment on lines +115 to +125
// Only the public certificate is written. The key stays where it was
// generated, and the store can find it again by thumbprint.
using (Certificate publicOnly = Certificate.FromRawData(certificateWithPrivateKey.RawData))
{
await store.AddAsync(publicOnly, null, cancellationToken).ConfigureAwait(false);
}

lock (m_lock)
{
m_pending[Scope(context)] = certificateWithPrivateKey.Thumbprint;
}
Comment on lines +72 to +88
lock (m_lock)
{
var providers = new List<ICryptoProvider>();
AddDistinct(providers, m_default ?? m_fallback);

foreach (ICryptoProvider provider in m_byPurposeAndPolicy.Values)
{
AddDistinct(providers, provider);
}

foreach (ICryptoProvider provider in m_byPurpose.Values)
{
AddDistinct(providers, provider);
}

return new ArrayOf<ICryptoProvider>(providers.ToArray());
}
Comment on lines +96 to +107
try
{
using var store = new Pkcs11CertificateStore(
NUnitTelemetryContext.Create(),
Pkcs11TestEnvironment.CreateOptions());

store.Open(Pkcs11TestEnvironment.CreateStorePath(), noPrivateKeys: false);

using CertificateCollection certificates = store.EnumerateAsync()
.GetAwaiter()
.GetResult();

Comment on lines 1093 to +1097
byte[]? signingKey = null,
bool signOnly = false,
uint tokenId = 0,
uint lastSequenceNumber = 0)
uint lastSequenceNumber = 0,
HMAC? hmac = null)
The last CI failure was C_DecryptInit returning CKR_ARGUMENTS_BAD. That is not
a defect in the parameters: SoftHSM2 implements RSA-OAEP only with SHA-1 and
MGF1-SHA1 and rejects anything else. Which policies a token can serve is a
property of the device, so the test now skips with the mechanism and return
value named, the same way the rest of the suite skips policies a platform does
not support.

A PKCS#1 v1.5 decrypt round trip is added alongside it, so the decryption path
is still genuinely covered on SoftHSM rather than only on hardware that
implements OAEP with SHA-256.

Everything else passed against the real token, including PKCS#1 v1.5 and PSS
signing, which is what this package existed to prove.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0502132e-320a-48dc-84e2-bcd8d854aa70
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: fully pluggable cryptography provider to enable hardware-backed keys (TPM 2.0 / secure elements)

3 participants