mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
7fe7794a19
> Follow-up to #4703 (now merged to master; this PR has been retargeted
to `master`).
Part of #4510. #4703 enabled the CBC ECC policies on net48 through a
BouncyCastle raw-ECDH polyfill. The AEAD policies were still unavailable
there because .NET Framework has no `AesGcm` or `ChaCha20Poly1305`. This
PR adds those ciphers the same way, so on net48 all of these are now
offered:
`ECC_{nistP256,nistP384,brainpoolP256r1,brainpoolP384r1}_AesGcm` /
`_ChaChaPoly`, `RSA_DH_AesGcm`, `RSA_DH_ChaChaPoly`.
## Changes
- **Polyfill**
`src/Opc.Ua.Security.Certificates/Org.BouncyCastle/AeadCiphers.cs`
(internal, `#if NETFRAMEWORK`, next to `EcdhRawAgreement`): `AesGcm` and
`ChaCha20Poly1305` types with the .NET 8 BCL surface. They wrap
BouncyCastle's `GcmBlockCipher(AesEngine)` and
`Org.BouncyCastle.Crypto.Modes.ChaCha20Poly1305`.
- A tag mismatch throws `CryptographicException`, never BC's
`InvalidCipherTextException`, so
`PlatformSymmetricCryptoProvider.DecryptAuthenticated` still returns
`false` and the channel still raises a protocol error.
- GCM releases plaintext blocks before it checks the tag, so plaintext
stays in a scratch buffer and is copied out only after the tag verifies.
On failure the destination is cleared, as the BCL does. Scratch buffers
and the key copy are zeroed.
- **Opc.Ua.Core**: aliases the polyfill types on `NETFRAMEWORK` and
removes the `NET8_0_OR_GREATER` gates in `SecurityPolicies`
(`SupportsAesGcmPolicy` / `SupportsChaCha20Poly1305Policy`),
`CryptoUtils` (`SymmetricEncryptAndSign` / `SymmetricDecryptAndVerify`,
AEAD helpers) and `PlatformSymmetricCryptoProvider`. The channel, the
provider facet and the encrypted ECC user tokens (`EncryptedSecret`,
through `CryptoUtils`) now run the same code on every target. .NET 8+
keeps the BCL ciphers.
- **FIPS** (requested by the #4703 session, after a2ecf13b1): under
`CryptoCompliancePolicy.FipsOnly`, `CryptoCompliance.IsPolicyPermitted`
now also withholds every AEAD policy on .NET Framework. That adds
`RSA_DH_AesGcm`, which is FIPS-approved and not ECC. The BouncyCastle
cipher runs outside any validated module and the user-token path
bypasses the provider registry, so the auditor cannot see it. Tests and
`docs/CryptoProvider.md` are updated to match.
- **Docs**: EccProfiles (Known Limitations), migration `certificates.md`
(and the bundled skill copy), ProductionChecklist, Profiles,
WhatsNewIn2.0, CryptoProvider. They state that on net48 these primitives
run in managed BouncyCastle, which is not a validated (FIPS) module and
not hardware-accelerated or hardened against cache-timing attacks like
CNG AES.
## Tests
- New `AeadPolyfillTests` (Core.Tests):
- Known answers: NIST GCM test cases 2/4 (AES-128) and 14/16 (AES-256),
plus RFC 8439 §2.8.2 for ChaCha20-Poly1305. They run through
`PlatformSymmetricCryptoProvider` on every TFM and through the polyfill
directly.
- Tampering with the tag, the ciphertext or the AAD gives `false` from
the provider, and `CryptographicException` with a cleared plaintext from
the polyfill.
- Argument validation.
- **BCL cross-check:** on .NET 8+, Core.Tests also compiles
`AeadCiphers.cs` (`AEAD_POLYFILL_CROSSCHECK`, test-only
`BouncyCastle.Cryptography` reference). Polyfill and BCL must produce
identical ciphertext and tag, and each must decrypt the other's output:
3 algorithms × 8 message lengths (0 to 8192) × 3 AAD lengths.
- `CryptoProviderRegistryTests`: `RSA_DH_AesGcm` is permitted under
FipsOnly only off .NET Framework and withheld on it.
- Tests that `Assert.Ignore` when AEAD is unavailable now run on net48,
e.g. `EccSecretRejectsDifferentNegotiatedPolicyAsync` and the
`RSA_DH_AesGcm` server session tests.
### Local results
| Run | Result |
|---|---|
| Core.Tests net48, full (before the FIPS commit) | 6049 passed, 0
failed, 88 skipped |
| Core.Tests crypto/AEAD/ECC/EncryptedSecret/Nonce subset, net48 /
net10.0 | 278 / 343 passed, 0 skipped |
| Server.Tests session-security/ECC subset, net48 | 128 passed, 0
skipped |
| `EccInteropTests` net48 vs **open62541 1.5.8 (OpenSSL)** peer | all 16
AEAD cases pass, 4 of them two-renewal token-renewal runs
(`ECC_nistP256_AesGcm`/`_ChaChaPoly` × Sign/SignAndEncrypt ×
Anonymous/UserName, both directions). Remaining 8 failures are the
documented open62541 CBC Sign-only HKDF differences in
`expected-differences.json` |
| `EccInteropTests` net48 vs 1.5.378 LegacyPeer | unchanged 26 passed /
28 skipped |
Note on the acceptance criterion: the 1.5.378 LegacyPeer does not
implement the AEAD policies, so its
`_AesGcm`/`_ChaChaPoly`/`*AeadTokenRenewalAsync` cases skip on every TFM
("The peer (UA-.NETStandard) does not declare …"). Before this PR they
skipped on the 2.0 side; now they get past it and stop at the peer
check. The open62541 peer is the real cross-stack check of the net48
AEAD path against a non-BouncyCastle implementation.
## PubSub DTLS on net48
The DTLS 1.3 transport (`src/Opc.Ua.PubSub.Udp/Dtls`) was compiled out
on .NET Framework, so net48 registered no DTLS profiles. It now reuses
the same polyfills (`Opc.Ua.Security.Certificates` gains
`InternalsVisibleTo Opc.Ua.PubSub.Udp`):
- The `NET8_0_OR_GREATER` gates in the handshake, ECDHE, record
protection and profile probe are removed.
- Two .NET 8 APIs get net48 fallbacks: the AES-ECB record-number mask
uses `CreateEncryptor`, and the handshake random uses
`RandomNumberGenerator.Create`.
- The HKDF probe now checks the managed `DtlsHkdf` that the key schedule
actually uses.
- The public `IsAeadSupported` / `IsRawSharedSecretSupported` probes are
kept and now return `true` everywhere.
- The DTLS tests now also run on net48. The net48 "registers no
profiles" test now asserts that the NIST AEAD profiles are registered.
The socket transport-peer tests and the benchmarks stay .NET 8-only
because they use .NET 8 socket APIs.
- `docs/PubSub.md` DTLS profile table: the net48 column is updated.
Results: PubSub.Udp.Tests net48, full suite: **229 passed, 0 skipped**.
It includes complete NIST AEAD, brainpool and integrity-only handshakes
and ChaCha20 records. The DTLS subset is 76 on net48 vs 81 on net10.0;
the 5 still-gated socket-peer tests are the difference.
Note: DTLS profile selection does not consult `CryptoCompliancePolicy`
on any target. That is pre-existing and unchanged here.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
638 lines
30 KiB
Markdown
638 lines
30 KiB
Markdown
# 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](#the-short-version)
|
|
- [Holding a private key in a device](#holding-a-private-key-in-a-device)
|
|
- [Selecting a provider per purpose and per policy](#selecting-a-provider-per-purpose-and-per-policy)
|
|
- [Generating a key inside a device](#generating-a-key-inside-a-device)
|
|
- [Certificate renewal with a device held key](#certificate-renewal-with-a-device-held-key)
|
|
- [Validation status, compliance and audit](#validation-status-compliance-and-audit)
|
|
- [What can and cannot be claimed about FIPS](#what-can-and-cannot-be-claimed-about-fips)
|
|
- [Substituting the symmetric primitives](#substituting-the-symmetric-primitives)
|
|
- [Using a key served over a network](#using-a-key-served-over-a-network)
|
|
- [PubSub](#pubsub)
|
|
- [Contributing a security policy](#contributing-a-security-policy)
|
|
- [Limitations](#limitations)
|
|
|
|
## The short version
|
|
|
|
`System.Security.Cryptography.RSA` and `ECDsa` are abstract classes, and the stack routes every
|
|
private-key operation through them. Implementations backed by hardware and cloud services already exist —
|
|
`RSACng` over a TPM key storage provider, `Pkcs11Interop`, Azure's `RSAKeyVault`. The stack therefore does
|
|
**not** define its own signing interface, because doing so would make all of those unusable.
|
|
|
|
What the provider model adds is the part the platform does not express:
|
|
|
|
| Concern | Type |
|
|
|---|---|
|
|
| Which operations a provider can serve | `ICryptoProvider`, `CryptoCapability` |
|
|
| Which provider serves which operation | `ICryptoProviderRegistry`, `CryptoPurpose` |
|
|
| The symmetric, derivation and random operations themselves | `ISymmetricCryptoProvider`, `IKeyDerivationProvider`, `ISecureRandomSource` |
|
|
| What may be said about the module behind it | `CryptoValidationStatus` |
|
|
| How strictly that is enforced | `CryptoCompliancePolicy` |
|
|
| Where a new key comes from | `IKeyPairGenerator` |
|
|
| Where a certificate and its key live | `ICertificateStoreProvider`, `ICertificateStore` |
|
|
|
|
## Holding a private key in a device
|
|
|
|
A device-held key is represented by a **detached** private key: the `Certificate` holds the key alongside
|
|
the `X509Certificate2` rather than inside it.
|
|
|
|
```csharp
|
|
// 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.
|
|
|
|
```csharp
|
|
services.AddOpcUa()
|
|
.AddPkcs11CertificateStore(new Pkcs11TokenOptions
|
|
{
|
|
ModulePath = "/usr/lib/softhsm/libsofthsm2.so",
|
|
TokenLabel = "opcua",
|
|
PinProvider = () => secretStore.Read("token-pin")
|
|
})
|
|
.AddPkcs11CryptoProvider(CryptoPurpose.ApplicationInstanceKey);
|
|
```
|
|
|
|
Stores are then addressed with an RFC 7512 URI, so an existing configuration moves to a token by changing
|
|
only the store path:
|
|
|
|
```
|
|
pkcs11:token=opcua;object=server?module-path=/usr/lib/softhsm/libsofthsm2.so
|
|
```
|
|
|
|
The store binds the token key with `CopyWithDetachedPrivateKey` for the reason below. Signing supports
|
|
PKCS#1 v1.5 and PSS, decryption supports OAEP and PKCS#1 v1.5, and ECDSA is supported on the curves the
|
|
token implements. Revocation lists are not held on a token, and objects are provisioned with the vendor's
|
|
tools rather than through the store.
|
|
|
|
On Linux the module is loaded through `DllImport("libdl")`, and glibc 2.34 folded `libdl` into `libc`.
|
|
Distributions that ship only the `libdl.so.2` ABI stub need `libc6-dev` (or an equivalent `libdl.so`
|
|
symlink) or the first call fails with `DllNotFoundException`. That is a property of the interop library
|
|
rather than of this package.
|
|
|
|
### Why not `CopyWithPrivateKey`?
|
|
|
|
`X509Certificate2.CopyWithPrivateKey` cannot be used for this. On Windows the certificate layer has fast
|
|
paths only for `RSACng` and `RSACryptoServiceProvider`; for any other implementation it falls back to
|
|
exporting the private parameters, which a non-extractable key refuses by definition:
|
|
|
|
| Approach | Result on Windows |
|
|
|---|---|
|
|
| `CopyWithPrivateKey(custom RSA)` | `CryptographicException` |
|
|
| `CopyWithPrivateKey(RSACng, ExportPolicy=None)` | works |
|
|
| `CopyWithDetachedPrivateKey(...)` | works, on every platform |
|
|
|
|
`CertificateRequest.CreateSelfSigned` is affected for the same reason, since it calls
|
|
`CopyWithPrivateKey` internally.
|
|
|
|
A CNG-backed key may use either route. Everything else — PKCS#11, cloud key services, bespoke
|
|
implementations — must use `CopyWithDetachedPrivateKey`.
|
|
|
|
### Lifetime
|
|
|
|
The detached key is shared by every handle created with `AddRef()` and is disposed once, with the last
|
|
handle, unless you pass `ownsPrivateKey: false`. Each call to `GetRSAPrivateKey()` returns an independent
|
|
non-owning view, so the usual `using` around the returned object is safe and does not destroy the device
|
|
key.
|
|
|
|
Exporting such a certificate as PKCS#12 throws rather than silently producing a file without a key.
|
|
|
|
## Selecting a provider per purpose and per policy
|
|
|
|
Real deployments mix sources. Selection is therefore a resolution over three discriminators — purpose,
|
|
security policy and certificate type — with the most specific registration winning:
|
|
|
|
```
|
|
(purpose, policy) → purpose → registered default → platform
|
|
```
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
var application = new ApplicationInstance { KeyPairGenerator = new TpmKeyPairGenerator() };
|
|
```
|
|
|
|
The builder arrives with the subject, subject alternative names and lifetime already set. An
|
|
implementation only chooses where the key comes from and how the certificate is signed.
|
|
|
|
An implementation backed by hardware **cannot** call the parameterless `CreateForRSA()` or
|
|
`CreateForECDsa()`, because those generate a key in software. It must supply the public key generated in
|
|
the device with `SetRSAPublicKey` / `SetECDsaPublicKey`, and sign with an `X509SignatureGenerator` that
|
|
calls back into the device.
|
|
|
|
For the Part 12 push flow the equivalent seam is `IPushCertificateKeyGenerator`, which is already
|
|
registered through dependency injection and can be replaced the same way.
|
|
|
|
## Certificate renewal with a device held key
|
|
|
|
Only one of the two Part 12 flows works with a key that cannot leave its device:
|
|
|
|
| Flow | Compatible? |
|
|
|---|---|
|
|
| `CreateSigningRequest(RegeneratePrivateKey = true)` then `UpdateCertificate` with no private key | ✅ the key is generated in the device and only a certificate request leaves it |
|
|
| `UpdateCertificate` with a private key supplied | ❌ raw key material is pushed into the server |
|
|
|
|
Between the two calls the regenerated key has to be staged. `HardwarePendingCertificateKeyStore` does this
|
|
for device-held keys: there is nothing to export, because the device already is the durable store, so only
|
|
the association between the pending certificate and its scope is kept. A software key is declined, so the
|
|
caller falls back to a store that knows how to protect exportable material.
|
|
|
|
## Validation status, compliance and audit
|
|
|
|
Every provider declares what may be said about the module behind it:
|
|
|
|
| Level | Meaning |
|
|
|---|---|
|
|
| `FipsValidated` | The provider names a validation certificate for its module |
|
|
| `FipsCapablePlatform` | It defers to platform cryptography, which is validated when the OS is configured for it |
|
|
| `Uncertified` | It carries no validation — a third-party library or a bespoke implementation |
|
|
| `Unknown` | It declined to say. Treated as uncertified, and always reported |
|
|
|
|
`CryptoCompliancePolicy` decides how strictly this is enforced:
|
|
|
|
| Policy | Effect |
|
|
|---|---|
|
|
| `Permissive` (default) | Existing behaviour. Nothing is filtered and nothing is warned about |
|
|
| `WarnOnUncertified` | Every provider carrying no validation is reported |
|
|
| `FipsOnly` | Providers carrying no validation are refused and the server does not start |
|
|
|
|
```csharp
|
|
using var auditor = new CryptoProviderAuditor(registry, telemetry, CryptoCompliancePolicy.FipsOnly);
|
|
auditor.Report(); // writes the effective configuration to the log
|
|
auditor.ThrowIfNotCompliant(); // refuses to continue if anything is uncertified
|
|
```
|
|
|
|
Metrics are published regardless of policy, because they are pull-based and cost nothing when nobody reads
|
|
them:
|
|
|
|
| Instrument | Meaning |
|
|
|---|---|
|
|
| `opc.ua.crypto.providers` | One measurement per provider, tagged with name and validation level |
|
|
| `opc.ua.crypto.providers.uncertified` | How many providers in use carry no validation |
|
|
|
|
## What can and cannot be claimed about FIPS
|
|
|
|
**.NET holds no cryptographic module validation certificate of its own.** It calls through to the module
|
|
the operating system supplies — CNG on Windows, OpenSSL on Linux, CoreCrypto on macOS. Whether that module
|
|
is running in a validated mode is a property of how the machine is configured, not of this stack.
|
|
|
|
What can honestly be said: *with `FipsOnly`, the stack performs no cryptography outside the platform
|
|
modules, and those modules are validated when the operating system is configured for it.*
|
|
|
|
What cannot be said: that the stack itself is validated.
|
|
|
|
These algorithms are **not** FIPS-approved and are enabled by default:
|
|
|
|
| Algorithm | Note |
|
|
|---|---|
|
|
| ChaCha20-Poly1305 | Not a NIST-approved algorithm |
|
|
| Brainpool P-256r1 / P-384r1 | Not in SP 800-186 |
|
|
| Curve25519 / X25519 | Not an approved curve |
|
|
| SHA-1 and P-SHA1 (Basic128Rsa15, Basic256) | Deprecated for new signatures by SP 800-131A |
|
|
|
|
`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** validated — the validated Bouncy Castle product is a
|
|
separate, commercially licensed distribution — so **that target framework cannot make a FIPS claim at
|
|
all**.
|
|
|
|
### What `FipsOnly` actually enforces at startup
|
|
|
|
`CryptoProviderAuditor.ThrowIfNotCompliant()` refuses to start when a provider bound to an operation
|
|
would not perform it. Carrying the facet is necessary but not sufficient: `Supports(algorithm)` is
|
|
consulted again at the point of use, and a provider that answers `false` there is bypassed in favour of
|
|
the platform *for that algorithm alone*. So the audit checks the algorithms of every policy the
|
|
application actually offers, and names what would fall through:
|
|
|
|
```
|
|
The compliance policy requires validated cryptography to perform every operation, but the
|
|
provider resolved for these cannot perform them and the platform would be used instead:
|
|
ChannelSymmetric (ChaCha20Poly1305) for ...#ECC_nistP256_ChaChaPoly, KeyDerivation (HkdfSha256) for ...
|
|
```
|
|
|
|
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.
|
|
|
|
```csharp
|
|
public sealed class ValidatedModule : ICryptoProvider, ISymmetricCryptoProvider
|
|
{
|
|
public string Name => "AcmeFIPS";
|
|
|
|
public CryptoValidationStatus Validation => new(
|
|
CryptoValidationLevel.FipsValidated, "Acme Cryptographic Module", "CMVP #1234");
|
|
|
|
public ArrayOf<CryptoCapability> Capabilities { get; } =
|
|
new(new[] { new CryptoCapability(CryptoPurpose.ChannelSymmetric) });
|
|
|
|
// ... the operations
|
|
}
|
|
|
|
services.AddOpcUa()
|
|
.AddCryptoProvider(crypto => crypto
|
|
.For(CryptoPurpose.ChannelSymmetric).Use(module)
|
|
.For(CryptoPurpose.KeyDerivation).Use(module)
|
|
.For(CryptoPurpose.RandomNumberGeneration).Use(module));
|
|
```
|
|
|
|
**The consumer this exists for is a validated software module that must perform every operation, not
|
|
hardware offload.** A device round trip per message would destroy throughput, and nothing here makes that
|
|
viable.
|
|
|
|
`RandomNumberGeneration` is the one purpose with process-wide reach: nonces are created from many places
|
|
that have no container in scope, so an **unscoped** binding also becomes the process's nonce source. A
|
|
binding made for a single security policy — `.For(purpose, policyUri).Use(module)` — deliberately does
|
|
not, because it would otherwise redirect nonce generation for every other policy as well.
|
|
|
|
### What it costs when you do not use it
|
|
|
|
Nothing, by construction. `CryptoProviderFacets` returns `null` both when no registry is configured and
|
|
when resolution lands on the platform provider, because the platform facets perform exactly the code the
|
|
channel would otherwise run inline. A `null` facet tells the channel to take its existing path, so the
|
|
per-message code has no interface dispatch at all unless a provider was registered.
|
|
|
|
Resolution happens once, in `CalculateSymmetricKeySizes`, and the result is held for the life of the
|
|
channel. `SymmetricChannelCryptoBenchmarks` measures both paths — `EncryptSignThenDecryptVerify` is the
|
|
baseline and `EncryptSignThenDecryptVerifyThroughProvider` is the same work through the seam — so the
|
|
cost of the indirection is measured rather than assumed.
|
|
|
|
### A provider that cannot do what it was bound to
|
|
|
|
Binding a provider to `ChannelSymmetric` without implementing
|
|
`ISymmetricCryptoProvider` would 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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
services.AddOpcUa()
|
|
.AddCryptoProvider(crypto => crypto
|
|
.For(CryptoPurpose.ChannelSymmetric).Use(module))
|
|
.AddPubSub(...);
|
|
```
|
|
|
|
The policies resolve the provider once, when they are constructed, and hold it;
|
|
nothing consults a registry per message. A provider that does not declare the
|
|
algorithms a policy needs is ignored rather than used, so a configuration mistake
|
|
does not stop publishing. `IPubSubSecurityPolicy` is unchanged — a provider is
|
|
supplied through the constructor, so implementations of that interface outside
|
|
this stack are unaffected.
|
|
|
|
The wrapper resolver selects the bundle it wraps with from the registered
|
|
policies rather than from a static default, which is what makes the registration
|
|
above take effect. Outside the container, `PubSubApplicationBuilder` does the
|
|
same with the policies it holds, and `WithSecurityPolicySelector` overrides the
|
|
choice per connection:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
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 and is what the paths that run before any container exists —
|
|
configuration loading, most obviously — resolve their policies through:
|
|
|
|
```csharp
|
|
string? uri = SecurityPolicies.Default.GetUri("Basic256Sha256");
|
|
```
|
|
|
|
Construct a registry directly when you want an isolated set, which is also what makes the policy set
|
|
testable without touching process-wide state:
|
|
|
|
```csharp
|
|
using var policies = new SecurityPolicies(telemetry);
|
|
using IDisposable registration = policies.Register(customPolicy);
|
|
```
|
|
|
|
The registry creates its logger from the `ITelemetryContext` it is given, which is why `Encrypt`,
|
|
`Decrypt` and the signature helpers take no `ILogger` argument.
|
|
|
|
`PlatformSupport` is what decides whether the policy is offered on the machine it is running on, so a
|
|
policy whose algorithms are unavailable is filtered out the same way the built-in ones are. The
|
|
`ECC_curve25519` and `ECC_curve448` profiles are the worked example: they ship in the tree but are not
|
|
advertised, and a consumer can light them up from outside without rebuilding the stack — which is what
|
|
`RegisterLightsUpCurvePoliciesFromOutsideCore` asserts.
|
|
|
|
Registering a policy makes it **advertised and resolvable**, 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](#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.
|
|
|
|
## Related
|
|
|
|
- [Certificates](Certificates.md) — how certificates are used in the certificate stores
|
|
- [CertificateManager](CertificateManager.md) — certificate lifecycle and Part 12 push management
|
|
- [ECC Certificates](EccProfiles.md) — the elliptic curve security policies
|
|
- [Dependency Injection](DependencyInjection.md) — how features are registered
|
|
- [Diagnostics](Diagnostics.md) — telemetry, audit events and metrics
|
|
- [Native AOT](NativeAoT.md) — why plugin discovery is registration-based
|