Certificates and ICertificateProvider
August 10, 2026 · View on GitHub
When to read this: Read this for the new ref-counted
Certificatewrapper, the segregated-interfaceCertificateManagerdesign, theICertificateProvidercache, and the obsoletedX509Certificate2direct-exposure APIs.
Centralised certificate cache via ICertificateProvider
A new public ICertificateProvider interface exposes the existing
CertificateCache for resolving private-key certs on demand:
public interface ICertificateProvider
{
Certificate? TryGetPrivateKeyCertificate(string thumbprint); // sync
ValueTask<Certificate?> GetPrivateKeyCertificateAsync(
CertificateIdentifier identifier,
ICertificatePasswordProvider? passwordProvider = null,
string? applicationUri = null,
CancellationToken ct = default);
}
CertificateManager exposes one via the new CertificateProvider
property; ICertificateManager likewise. The provider follows the
TryGet → async ValueTask pattern: cache hits complete
synchronously without allocations; misses fall through to
CertificateIdentifierResolver.LoadPrivateKeyAsync and write the
loaded cert back into the cache.
Wire it through to the new X509IdentityTokenHandler /
UserIdentity.CreateAsync overloads:
UserIdentity userIdentity = await UserIdentity.CreateAsync(
certificateIdentifier,
passwordProvider,
configuration.CertificateManager.CertificateProvider,
ct);
Certificate Management
Certificates with an empty distinguished name are always rejected
A certificate whose subject or issuer is an empty distinguished name is now rejected with the non-suppressible Bad_CertificateInvalid, on every target framework. An empty name is an empty RDNSequence: it identifies nothing, and two unrelated issuers become indistinguishable, so the certificate can never take part in a trust decision. RFC 5280 §4.1.2.4 requires a non-empty issuer, and §4.1.2.6 only permits an empty subject for an end entity carrying a critical subjectAltName, which a CA may never do.
On 1.5.378 the outcome depended on the platform: .NET's X.509 and PEM readers accept these certificates, so they could reach a trust list and be used. The check now runs in the validator before the trust-list lookup and the chain build, so trusting such a certificate, enabling AutoAcceptUntrustedCertificates, or approving the error from the validation callback will not make it pass.
Reading a PEM file also skips these certificates rather than importing them. PEMReader.ImportPublicKeysFromPEM drops only the offending entry and returns the rest, so one malformed certificate cannot empty an otherwise usable trust list.
Migration steps:
- Re-issue any certificate that carries an empty subject or issuer. There is no configuration switch to accept one.
- If a trust list silently shrinks after the upgrade, the dropped entries carried an empty name;
DistinguishedNameUtils.HasEmptyDistinguishedNamereports the same verdict the stack applies.
if (DistinguishedNameUtils.HasEmptyDistinguishedName(certificate))
{
// Bad_CertificateInvalid - re-issue with a real subject and issuer.
}
Certificate and CertificateCollection wrapper types
X509Certificate2 and X509Certificate2Collection are no longer used directly in the public API. They are replaced by Certificate and CertificateCollection (in Opc.Ua.Security.Certificates).
Migration steps:
// Before:
X509Certificate2 cert = new X509Certificate2(rawData);
X509Certificate2Collection certs = await store.Enumerate();
// After:
Certificate cert = new Certificate(rawData);
CertificateCollection certs = await store.EnumerateAsync();
Certificate implements reference counting. Call AddRef() before sharing a certificate across ownership boundaries, and Dispose() to release. The inner X509Certificate2 is disposed when the last reference is released.
For .NET interop, use certificate.AsX509Certificate2() which returns a copy the caller must dispose. The internal X509Certificate2 is accessible via the internal X509 property for InternalsVisibleTo friends.
CertificateBuilder.CreateForRSA() and CreateForECDsa() now return Certificate instead of X509Certificate2.
CertificateManager and segregated interfaces
A new centralized CertificateManager replaces the scattered certificate handling across CertificateValidator, CertificateIdentifier, CertificateTypesProvider, and CertificateFactory. It is composed of focused interfaces:
| Interface | Purpose | Location |
|---|---|---|
ICertificateRegistry | Read-only access to app certificates | Opc.Ua |
ICertificateTrustListManager | Named trust-list management | Opc.Ua |
ICertificateValidatorEx | Trust-list-scoped validation | Opc.Ua |
ICertificateLifecycle | Change notifications + cert updates | Opc.Ua |
ICertificateFactory | Stateless cert creation/parsing | Opc.Ua.Security.Certificates |
ICertificateIssuer | CA signing + CRL revocation | Opc.Ua.Security.Certificates |
ICertificateStoreProvider | Pluggable store backends | Opc.Ua |
The CertificateManager is automatically initialized by ServerBase and ApplicationInstance during startup. Access it via ServerBase.CertificateManager or ApplicationInstance.CertificateManager.
Trust-lists are now named and extensible:
// Well-known: TrustListIdentifier.Peers, .Users, .Https, .Rejected
// Custom:
manager.RegisterTrustList(new TrustListIdentifier("MqttBrokers"),
trustedStorePath: "...", issuerStorePath: "...");
// Validate against any trust-list
var result = await manager.ValidateAsync(cert, TrustListIdentifier.Users);
Subscribe to certificate changes:
manager.CertificateChanges.Subscribe(observer);
See CertificateManager.md for the full API reference and usage guide.
CertificateIdentifier is metadata-only
CertificateIdentifier no longer caches a Certificate, no longer implements IDisposable, and the cert-bearing constructors / instance methods have been removed. Use CertificateIdentifierResolver to materialize a Certificate from an identifier.
Removed members:
Certificateget/set property and the cachedm_certificatefield.IDisposabledeclaration,Dispose(),DisposeCertificate().- Constructors
CertificateIdentifier(Certificate),CertificateIdentifier(Certificate, CertificateValidationOptions),CertificateIdentifier(byte[]). - Instance methods
FindAsync(...),LoadPrivateKeyAsync(char[], ...),LoadPrivateKeyExAsync(...),OpenStore(...). IOpenStoreinterface declaration onCertificateIdentifier.
RawData is now backed by an explicit byte[] field. The setter still derives SubjectName / Thumbprint / CertificateType from the parsed raw bytes.
ICertificateRegistry.GetIssuersAsync now returns IList<CertificateIssuerReference> (a public sealed record with Certificate Certificate, CertificateValidationOptions Options) instead of IList<CertificateIdentifier>. Existing callers must update the list type and switch from CertificateIdentifier.Certificate to CertificateIssuerReference.Certificate.
Migration patterns:
| Before (legacy) | After |
|---|---|
var id = new CertificateIdentifier(cert); | var id = new CertificateIdentifier { Thumbprint = cert.Thumbprint, SubjectName = cert.Subject, CertificateType = CertificateIdentifier.GetCertificateType(cert) }; |
var id = new CertificateIdentifier(rawData); | var id = new CertificateIdentifier { RawData = rawData }; |
id.Certificate (read) | await CertificateIdentifierResolver.ResolveAsync(id, registry, needPrivateKey: false, applicationUri, telemetry, ct) |
id.Certificate = cert; | Drop the assignment. Cert lifecycle is owned by CertificateManager (use ICertificateLifecycle.UpdateApplicationCertificateAsync) or by a local variable. |
await id.FindAsync(true, applicationUri, telemetry, ct) | await CertificateIdentifierResolver.LoadPrivateKeyAsync(id, passwordProvider, applicationUri, telemetry, ct) |
await id.LoadPrivateKeyExAsync(passwordProvider, applicationUri, telemetry, ct) | await CertificateIdentifierResolver.LoadPrivateKeyAsync(id, passwordProvider, applicationUri, telemetry, ct) |
id.OpenStore(telemetry) | CertificateIdentifierResolver.OpenStore(id, telemetry) |
using var id = new CertificateIdentifier(...); | var id = new CertificateIdentifier(...); (no using) |
IList<CertificateIdentifier> issuers = ...; var cert = issuers[i].Certificate; | IList<CertificateIssuerReference> issuers = ...; var cert = issuers[i].Certificate; |
See CertificateManager.md for the full migration walkthrough.
PushManagement transactions: TrustList/Certificate updates now require ApplyChanges
ConfigurationNodeManager now implements the full OPC UA Part 12 §7.10.2
PushManagement transaction model. This affects any code (Client or
custom ServerConfiguration-adjacent NodeManager) that calls the
TrustList AddCertificate / RemoveCertificate / CloseAndUpdate
Methods directly and previously observed the change take effect
immediately:
| 1.5.378 behavior | 2.0 behavior |
|---|---|
TrustList.AddCertificate / RemoveCertificate / CloseAndUpdate applied to the store immediately; a subsequent ReadTrustList reflected the change right away. | The same calls are staged. The store is unchanged — and ReadTrustList still returns the old contents — until the Session that made the call also calls ServerConfiguration.ApplyChanges. CloseAndUpdate now always reports applyChangesRequired = true (previously reported false when no restart was needed). |
| No concept of a per-Session transaction; concurrent Sessions could interleave TrustList/Certificate updates freely. | Exactly one transaction is active at a time, owned by the Session that started it; every other Session's staging call fails with Bad_TransactionPending until that Session calls ApplyChanges, calls the new CancelChanges Method, or closes. |
UpdateCertificate/CreateSelfSignedCertificate already required ApplyChanges (§7.7.5) — unaffected by this change. | Unchanged; now share the same transaction as TrustList updates, plus the new DeleteCertificate Method. |
No application code changes are required if you already call
ApplyChanges after every push-management write (the previously
recommended/spec-compliant pattern for Certificate updates) — this
change only affects code that assumed TrustList writes took effect
without it. See
CertificateManager.md § PushManagement Transactions
for the full model, the new standard nodes (SupportsTransactions,
DeleteCertificate, CancelChanges, TransactionDiagnostics), and the
IPushConfigurationTransactionCoordinator / IPendingCertificateKeyStore
DI replacement points.
For the final, per-requirement Part 12 support status — every ServerConfiguration/PushManagement, TrustList, certificate-alarm, KeyCredentialService and AuthorizationService requirement linked to its source and its automated tests, plus the applicable OPC UA profiles/conformance units and an explicit list of remaining optional/unsupported items — see the GDS Conformance Matrix. Two behaviours that were partial in earlier 2.0 previews are now complete: the TransactionDiagnostics §7.10.17 DataValue-status semantics, and the certificate-expiration / TrustList-staleness alarms (CertificateExpirationAlarmType / TrustListOutOfDateAlarmType), which now perform real active/inactive transitions and emit events rather than only populating property values at startup.
Optional ServerConfiguration surface now available (additive, opt-in)
ConfigurationNodeManager now also exposes the Optional OPC UA Part 12
§7.10.3 ServerConfigurationType members. No migration action is
required — the change is additive and every member is opt-in:
| Member | Change |
|---|---|
ApplicationUri, ProductUri, ApplicationType, ApplicationNames | Now always exposed on ServerConfiguration, seeded from the ApplicationConfiguration. Previously suppressed. Clients that browsed and found these absent will now find them present; no client that ignores them is affected. |
HasSecureElement, InApplicationSetup | Exposed only when configured via ServerConfigurationOptions. Suppressed otherwise (unchanged default). |
ResetToServerDefaults (§7.10.13) | Exposed only when an IServerConfigurationResetProvider is configured. Suppressed otherwise (unchanged default). |
ConfigurationFile (§7.10.20) | Exposed only when an IApplicationConfigurationFileProvider is configured. Suppressed otherwise (unchanged default). |
Configure the surface fluently (ConfigureServerConfiguration,
WithServerConfigurationReset, WithApplicationConfigurationFile), via DI, or
through the new ServerConfigurationOptions argument on
ConfigurationNodeManager/MainNodeManagerFactory. See
CertificateManager.md § Optional ServerConfiguration Surface.
MaxTrustListSize is advertised honestly and bounded by a safety ceiling
Previously, a ServerConfiguration.MaxTrustListSize of 0 (unlimited per OPC
UA Part 12 §8.4.5) was advertised to Clients as 0 while the server silently
enforced a hidden 1 MiB cap on TrustList Read/Write. The advertised
value and the enforced value could therefore disagree.
The server now:
- Advertises the honest effective limit.
ServerConfiguration.MaxTrustListSizenow reports the value the TrustList handlers actually enforce — never0while a finite cap is in force. A server configured withMaxTrustListSize = 0now advertises the safety ceiling (default 1 MiB) instead of0. - Adds a configurable resource-protection safety ceiling.
ServerConfigurationOptions.MaxTrustListSizeSafetyCeiling(default 1 MiB) bounds the actually-enforced size. The effective limit is:MaxTrustListSize == 0→ the ceiling;MaxTrustListSizeabove the ceiling → the ceiling; otherwise → the configuredMaxTrustListSize.
Migration action. No action is required for the common cases
(MaxTrustListSize of 0 or a finite value ≤ 1 MiB); enforcement is
unchanged and only the advertised value becomes honest. If you configured a
finite MaxTrustListSize larger than 1 MiB, raise
ServerConfigurationOptions.MaxTrustListSizeSafetyCeiling to at least that
value, otherwise the effective limit is clamped to the ceiling:
builder.ConfigureServerConfiguration(o =>
{
// Accept TrustLists up to 8 MiB (e.g. many large CRLs).
o.MaxTrustListSizeSafetyCeiling = 8 * 1024 * 1024;
});
The legacy TrustList constructor overloads (without an explicit safety
ceiling) are unchanged and remain fully backward compatible: a finite size is
honored exactly (never clamped) and 0 falls back to the 1 MiB default.
Obsoleted certificate APIs
The following APIs are marked [Obsolete] and will be removed in the next minor version. They remain
functional forwarders to the new design for binary-compatibility, but emit CS0618 warnings when used.
| Obsolete API | Replacement |
|---|---|
CertificateFactory.Create(ReadOnlyMemory<byte>) | Certificate.FromRawData(ReadOnlyMemory<byte>) or DefaultCertificateFactory.Instance.CreateFromRawData(...) |
CertificateFactory.CreateCertificate(string) | DefaultCertificateFactory.Instance.CreateCertificate(string) |
CertificateFactory.CreateCertificate(string, string, string, ArrayOf<string>) | DefaultCertificateFactory.Instance.CreateApplicationCertificate(...) |
CertificateFactory.CreateSigningRequest(...) | DefaultCertificateFactory.Instance.CreateSigningRequest(...) |
CertificateFactory.RevokeCertificate(...) | DefaultCertificateIssuer.Instance.RevokeCertificates(...) |
CertificateFactory.CreateCertificateWithPEMPrivateKey(...) | DefaultCertificateFactory.Instance.CreateWithPEMPrivateKey(...) |
CertificateFactory.CreateCertificateWithPrivateKey(...) | DefaultCertificateFactory.Instance.CreateWithPrivateKey(...) |
CertificateStoreIdentifier.RegisterCertificateStoreType(...) | Register ICertificateStoreProvider via dependency injection or pass to the CertificateManager constructor |
CertificateValidator (class) | ICertificateManager (composed of ICertificateValidatorEx for validation, ICertificateRegistry for app certs, ICertificateTrustListManager for trust lists, ICertificateLifecycle for change events). Construct via CertificateManagerFactory.Create(securityConfiguration, telemetry, ...) |
ICertificateValidator (interface) | ICertificateValidatorEx from ICertificateManager. The new interface returns a structured CertificateValidationResult (IsValid, StatusCode, Errors, IsBeingTrustedTransiently) instead of throwing. Per-error accept logic moves from the CertificateValidation event to the new CertificateValidationOptions.AcceptError callback. |
CertificateTypesProvider (class) | ICertificateRegistry (composed in ICertificateManager). Use using CertificateEntry? e = manager.AcquireApplicationCertificateBySecurityPolicy(securityPolicyUri); (caller-owned — dispose the entry). The entry already carries the chain: use e.IssuerChain / e.GetEncodedChainBlob(). |
ApplicationConfiguration.CertificateValidator (property) | ApplicationConfiguration.CertificateManager (parallel property — set in ApplicationInstance.CheckApplicationInstanceCertificatesAsync) |
ServerBase.CertificateValidator (property) | ServerBase.CertificateManager |
ServerBase.InstanceCertificateTypesProvider (property) | ServerBase.CertificateManager (use ICertificateRegistry surface) |
Lifecycle ordering.
configuration.CertificateManageris populated insideawait applicationInstance.CheckApplicationInstanceCertificatesAsync(...). Code that reads it before that call getsnull. The required ordering is:
- Construct
new ApplicationInstance(telemetry).- Load
ApplicationConfiguration(e.g. viaLoadApplicationConfigurationAsync).await applicationInstance.CheckApplicationInstanceCertificatesAsync(silent: false, ..., ct);.- Read
configuration.CertificateManager/ passconfiguration.CertificateManager.CertificateProvidertoUserIdentity.CreateAsync(...).
Migrating the CertificateValidator.CertificateValidation event
The legacy event with mutable e.Accept = true mutability has been replaced by
the structured CertificateValidationOptions.AcceptError callback:
// Before:
configuration.CertificateValidator.CertificateValidation += (s, e) =>
{
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
{
e.Accept = true;
}
};
await configuration.CertificateValidator.ValidateAsync(cert);
// After:
var options = new CertificateValidationOptions
{
AcceptError = (cert, error) =>
error.StatusCode == StatusCodes.BadCertificateUntrusted
};
CertificateValidationResult result =
await applicationInstance.CertificateManager.ValidateAsync(cert, options: options);
if (!result.IsValid)
{
throw new ServiceResultException(result.StatusCode);
}
Endpoint-aware validation helpers
CertificateValidator.ValidateApplicationUri(...) and
CertificateValidator.ValidateDomains(...) are now exposed as extension
methods on ICertificateValidatorEx in the
Opc.Ua.CertificateValidationExtensions static class. Existing call sites
that previously used the legacy class continue to work transparently.
The
CertificateFactory.DefaultKeySize/DefaultLifeTime/DefaultHashSizeconstants are intentionally not marked obsolete; they remain the canonical default values used across configuration sites.
To suppress CS0618 warnings while migrating, add at the top of affected files:
#pragma warning disable CS0618 // Obsolete API usage during migration
See also
- Related: identity.md, configuration.md, sessions-subscriptions.md.
- 2.0 migration index — analyzer quick-start + symptom → sub-doc table.
- Migration Guide — landing page across versions.